@ckeditor/ckeditor5-ckbox 41.1.0 → 41.3.0-alpha.0

Sign up to get free protection for your applications and to get access to all the features.
package/dist/index.js ADDED
@@ -0,0 +1,1713 @@
1
+ /**
2
+ * @license Copyright (c) 2003-2024, 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
+ import { Plugin, icons, Command, PendingActions } from '@ckeditor/ckeditor5-core/dist/index.js';
6
+ import { ButtonView, Notification } from '@ckeditor/ckeditor5-ui/dist/index.js';
7
+ import { Range } from '@ckeditor/ckeditor5-engine/dist/index.js';
8
+ import { createElement, toMap, CKEditorError, logError, global, abortableDebounce, retry } from '@ckeditor/ckeditor5-utils/dist/index.js';
9
+ import { decode } from 'blurhash';
10
+ import { FileRepository } from '@ckeditor/ckeditor5-upload/dist/index.js';
11
+ import { isEqual } from 'lodash-es';
12
+
13
+ /**
14
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
15
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
16
+ */
17
+ /**
18
+ * @module ckbox/ckboxui
19
+ */
20
+ /**
21
+ * The CKBoxUI plugin. It introduces the `'ckbox'` toolbar button.
22
+ */
23
+ class CKBoxUI extends Plugin {
24
+ /**
25
+ * @inheritDoc
26
+ */
27
+ static get pluginName() {
28
+ return 'CKBoxUI';
29
+ }
30
+ /**
31
+ * @inheritDoc
32
+ */
33
+ afterInit() {
34
+ const editor = this.editor;
35
+ // Do not register the `ckbox` button if the command does not exist.
36
+ // This might happen when CKBox library is not loaded on the page.
37
+ if (!editor.commands.get('ckbox')) {
38
+ return;
39
+ }
40
+ const t = editor.t;
41
+ const componentFactory = editor.ui.componentFactory;
42
+ componentFactory.add('ckbox', locale => {
43
+ const command = editor.commands.get('ckbox');
44
+ const button = new ButtonView(locale);
45
+ button.set({
46
+ label: t('Open file manager'),
47
+ icon: icons.browseFiles,
48
+ tooltip: true
49
+ });
50
+ button.bind('isOn', 'isEnabled').to(command, 'value', 'isEnabled');
51
+ button.on('execute', () => {
52
+ editor.execute('ckbox');
53
+ });
54
+ return button;
55
+ });
56
+ if (editor.plugins.has('ImageInsertUI')) {
57
+ const imageInsertUI = editor.plugins.get('ImageInsertUI');
58
+ imageInsertUI.registerIntegration({
59
+ name: 'assetManager',
60
+ observable: () => editor.commands.get('ckbox'),
61
+ buttonViewCreator: () => {
62
+ const button = this.editor.ui.componentFactory.create('ckbox');
63
+ button.icon = icons.imageAssetManager;
64
+ button.bind('label').to(imageInsertUI, 'isImageSelected', isImageSelected => isImageSelected ?
65
+ t('Replace image with file manager') :
66
+ t('Insert image with file manager'));
67
+ return button;
68
+ },
69
+ formViewCreator: () => {
70
+ const button = this.editor.ui.componentFactory.create('ckbox');
71
+ button.icon = icons.imageAssetManager;
72
+ button.withText = true;
73
+ button.bind('label').to(imageInsertUI, 'isImageSelected', isImageSelected => isImageSelected ?
74
+ t('Replace with file manager') :
75
+ t('Insert with file manager'));
76
+ button.on('execute', () => {
77
+ imageInsertUI.dropdownView.isOpen = false;
78
+ });
79
+ return button;
80
+ }
81
+ });
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
88
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
89
+ */
90
+ /**
91
+ * Converts image source set provided by the CKBox into an object containing:
92
+ * - responsive URLs for the "webp" image format,
93
+ * - one fallback URL for browsers that do not support the "webp" format.
94
+ */
95
+ function getImageUrls(imageUrls) {
96
+ const responsiveUrls = [];
97
+ let maxWidth = 0;
98
+ for (const key in imageUrls) {
99
+ const width = parseInt(key, 10);
100
+ if (!isNaN(width)) {
101
+ if (width > maxWidth) {
102
+ maxWidth = width;
103
+ }
104
+ responsiveUrls.push(`${imageUrls[key]} ${key}w`);
105
+ }
106
+ }
107
+ const imageSources = [{
108
+ srcset: responsiveUrls.join(','),
109
+ sizes: `(max-width: ${maxWidth}px) 100vw, ${maxWidth}px`,
110
+ type: 'image/webp'
111
+ }];
112
+ return {
113
+ imageFallbackUrl: imageUrls.default,
114
+ imageSources
115
+ };
116
+ }
117
+ /**
118
+ * Returns a workspace id to use for communication with the CKBox service.
119
+ *
120
+ * @param defaultWorkspaceId The default workspace to use taken from editor config.
121
+ */
122
+ function getWorkspaceId(token, defaultWorkspaceId) {
123
+ const [, binaryTokenPayload] = token.value.split('.');
124
+ const payload = JSON.parse(atob(binaryTokenPayload));
125
+ const workspaces = (payload.auth && payload.auth.ckbox && payload.auth.ckbox.workspaces) || [payload.aud];
126
+ if (!defaultWorkspaceId) {
127
+ return workspaces[0];
128
+ }
129
+ const role = payload.auth && payload.auth.ckbox && payload.auth.ckbox.role;
130
+ if (role == 'superadmin' || workspaces.includes(defaultWorkspaceId)) {
131
+ return defaultWorkspaceId;
132
+ }
133
+ return null;
134
+ }
135
+ /**
136
+ * Default resolution for decoding blurhash values.
137
+ * Relatively small values must be used in order to ensure acceptable performance.
138
+ */
139
+ const BLUR_RESOLUTION = 32;
140
+ /**
141
+ * Generates an image data URL from its `blurhash` representation.
142
+ */
143
+ function blurHashToDataUrl(hash) {
144
+ if (!hash) {
145
+ return;
146
+ }
147
+ try {
148
+ const resolutionInPx = `${BLUR_RESOLUTION}px`;
149
+ const canvas = document.createElement('canvas');
150
+ canvas.setAttribute('width', resolutionInPx);
151
+ canvas.setAttribute('height', resolutionInPx);
152
+ const ctx = canvas.getContext('2d');
153
+ /* istanbul ignore next -- @preserve */
154
+ if (!ctx) {
155
+ return;
156
+ }
157
+ const imageData = ctx.createImageData(BLUR_RESOLUTION, BLUR_RESOLUTION);
158
+ const decoded = decode(hash, BLUR_RESOLUTION, BLUR_RESOLUTION);
159
+ imageData.data.set(decoded);
160
+ ctx.putImageData(imageData, 0, 0);
161
+ return canvas.toDataURL();
162
+ }
163
+ catch (e) {
164
+ return undefined;
165
+ }
166
+ }
167
+ /**
168
+ * Sends the HTTP request.
169
+ *
170
+ * @internal
171
+ * @param config.url the URL where the request will be sent.
172
+ * @param config.method The HTTP method.
173
+ * @param config.data Additional data to send.
174
+ * @param config.onUploadProgress A callback informing about the upload progress.
175
+ */
176
+ function sendHttpRequest({ url, method = 'GET', data, onUploadProgress, signal, authorization }) {
177
+ const xhr = new XMLHttpRequest();
178
+ xhr.open(method, url.toString());
179
+ xhr.setRequestHeader('Authorization', authorization);
180
+ xhr.setRequestHeader('CKBox-Version', 'CKEditor 5');
181
+ xhr.responseType = 'json';
182
+ // The callback is attached to the `signal#abort` event.
183
+ const abortCallback = () => {
184
+ xhr.abort();
185
+ };
186
+ return new Promise((resolve, reject) => {
187
+ signal.throwIfAborted();
188
+ signal.addEventListener('abort', abortCallback);
189
+ xhr.addEventListener('loadstart', () => {
190
+ signal.addEventListener('abort', abortCallback);
191
+ });
192
+ xhr.addEventListener('loadend', () => {
193
+ signal.removeEventListener('abort', abortCallback);
194
+ });
195
+ xhr.addEventListener('error', () => {
196
+ reject();
197
+ });
198
+ xhr.addEventListener('abort', () => {
199
+ reject();
200
+ });
201
+ xhr.addEventListener('load', () => {
202
+ const response = xhr.response;
203
+ if (!response || response.statusCode >= 400) {
204
+ return reject(response && response.message);
205
+ }
206
+ resolve(response);
207
+ });
208
+ /* istanbul ignore else -- @preserve */
209
+ if (onUploadProgress) {
210
+ xhr.upload.addEventListener('progress', evt => {
211
+ onUploadProgress(evt);
212
+ });
213
+ }
214
+ // Send the request.
215
+ xhr.send(data);
216
+ });
217
+ }
218
+ const MIME_TO_EXTENSION = {
219
+ 'image/gif': 'gif',
220
+ 'image/jpeg': 'jpg',
221
+ 'image/png': 'png',
222
+ 'image/webp': 'webp',
223
+ 'image/bmp': 'bmp',
224
+ 'image/tiff': 'tiff'
225
+ };
226
+ /**
227
+ * Returns an extension a typical file in the specified `mimeType` format would have.
228
+ */
229
+ function convertMimeTypeToExtension(mimeType) {
230
+ return MIME_TO_EXTENSION[mimeType];
231
+ }
232
+ /**
233
+ * Tries to fetch the given `url` and returns 'content-type' of the response.
234
+ */
235
+ async function getContentTypeOfUrl(url, options) {
236
+ try {
237
+ const response = await fetch(url, {
238
+ method: 'HEAD',
239
+ cache: 'force-cache',
240
+ ...options
241
+ });
242
+ if (!response.ok) {
243
+ return '';
244
+ }
245
+ return response.headers.get('content-type') || '';
246
+ }
247
+ catch {
248
+ return '';
249
+ }
250
+ }
251
+ /**
252
+ * Returns an extension from the given value.
253
+ */
254
+ function getFileExtension(file) {
255
+ const fileName = file.name;
256
+ const extensionRegExp = /\.(?<ext>[^.]+)$/;
257
+ const match = fileName.match(extensionRegExp);
258
+ return match.groups.ext.toLowerCase();
259
+ }
260
+
261
+ /**
262
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
263
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
264
+ */
265
+ // Defines the waiting time (in milliseconds) for inserting the chosen asset into the model. The chosen asset is temporarily stored in the
266
+ // `CKBoxCommand#_chosenAssets` and it is removed from there automatically after this time. See `CKBoxCommand#_chosenAssets` for more
267
+ // details.
268
+ const ASSET_INSERTION_WAIT_TIMEOUT = 1000;
269
+ /**
270
+ * The CKBox command. It is used by the {@link module:ckbox/ckboxediting~CKBoxEditing CKBox editing feature} to open the CKBox file manager.
271
+ * The file manager allows inserting an image or a link to a file into the editor content.
272
+ *
273
+ * ```ts
274
+ * editor.execute( 'ckbox' );
275
+ * ```
276
+ *
277
+ * **Note:** This command uses other features to perform the following tasks:
278
+ * - To insert images it uses the {@link module:image/image/insertimagecommand~InsertImageCommand 'insertImage'} command from the
279
+ * {@link module:image/image~Image Image feature}.
280
+ * - To insert links to other files it uses the {@link module:link/linkcommand~LinkCommand 'link'} command from the
281
+ * {@link module:link/link~Link Link feature}.
282
+ */
283
+ class CKBoxCommand extends Command {
284
+ /**
285
+ * @inheritDoc
286
+ */
287
+ constructor(editor) {
288
+ super(editor);
289
+ /**
290
+ * A set of all chosen assets. They are stored temporarily and they are automatically removed 1 second after being chosen.
291
+ * Chosen assets have to be "remembered" for a while to be able to map the given asset with the element inserted into the model.
292
+ * This association map is then used to set the ID on the model element.
293
+ *
294
+ * All chosen assets are automatically removed after the timeout, because (theoretically) it may happen that they will never be
295
+ * inserted into the model, even if the {@link module:link/linkcommand~LinkCommand `'link'`} command or the
296
+ * {@link module:image/image/insertimagecommand~InsertImageCommand `'insertImage'`} command is enabled. Such a case may arise when
297
+ * another plugin blocks the command execution. Then, in order not to keep the chosen (but not inserted) assets forever, we delete
298
+ * them automatically to prevent memory leakage. The 1 second timeout is enough to insert the asset into the model and extract the
299
+ * ID from the chosen asset.
300
+ *
301
+ * The assets are stored only if
302
+ * the {@link module:ckbox/ckboxconfig~CKBoxConfig#ignoreDataId `config.ckbox.ignoreDataId`} option is set to `false` (by default).
303
+ *
304
+ * @internal
305
+ */
306
+ this._chosenAssets = new Set();
307
+ /**
308
+ * The DOM element that acts as a mounting point for the CKBox dialog.
309
+ */
310
+ this._wrapper = null;
311
+ this._initListeners();
312
+ }
313
+ /**
314
+ * @inheritDoc
315
+ */
316
+ refresh() {
317
+ this.value = this._getValue();
318
+ this.isEnabled = this._checkEnabled();
319
+ }
320
+ /**
321
+ * @inheritDoc
322
+ */
323
+ execute() {
324
+ this.fire('ckbox:open');
325
+ }
326
+ /**
327
+ * Indicates if the CKBox dialog is already opened.
328
+ *
329
+ * @protected
330
+ * @returns {Boolean}
331
+ */
332
+ _getValue() {
333
+ return this._wrapper !== null;
334
+ }
335
+ /**
336
+ * Checks whether the command can be enabled in the current context.
337
+ */
338
+ _checkEnabled() {
339
+ const imageCommand = this.editor.commands.get('insertImage');
340
+ const linkCommand = this.editor.commands.get('link');
341
+ if (!imageCommand.isEnabled && !linkCommand.isEnabled) {
342
+ return false;
343
+ }
344
+ return true;
345
+ }
346
+ /**
347
+ * Creates the options object for the CKBox dialog.
348
+ *
349
+ * @returns The object with properties:
350
+ * - theme The theme for CKBox dialog.
351
+ * - language The language for CKBox dialog.
352
+ * - tokenUrl The token endpoint URL.
353
+ * - serviceOrigin The base URL of the API service.
354
+ * - forceDemoLabel Whether to force "Powered by CKBox" link.
355
+ * - dialog.onClose The callback function invoked after closing the CKBox dialog.
356
+ * - assets.onChoose The callback function invoked after choosing the assets.
357
+ */
358
+ _prepareOptions() {
359
+ const editor = this.editor;
360
+ const ckboxConfig = editor.config.get('ckbox');
361
+ return {
362
+ theme: ckboxConfig.theme,
363
+ language: ckboxConfig.language,
364
+ tokenUrl: ckboxConfig.tokenUrl,
365
+ serviceOrigin: ckboxConfig.serviceOrigin,
366
+ forceDemoLabel: ckboxConfig.forceDemoLabel,
367
+ dialog: {
368
+ onClose: () => this.fire('ckbox:close')
369
+ },
370
+ assets: {
371
+ onChoose: (assets) => this.fire('ckbox:choose', assets)
372
+ }
373
+ };
374
+ }
375
+ /**
376
+ * Initializes various event listeners for the `ckbox:*` events, because all functionality of the `ckbox` command is event-based.
377
+ */
378
+ _initListeners() {
379
+ const editor = this.editor;
380
+ const model = editor.model;
381
+ const shouldInsertDataId = !editor.config.get('ckbox.ignoreDataId');
382
+ // Refresh the command after firing the `ckbox:*` event.
383
+ this.on('ckbox', () => {
384
+ this.refresh();
385
+ }, { priority: 'low' });
386
+ // Handle opening of the CKBox dialog.
387
+ this.on('ckbox:open', () => {
388
+ if (!this.isEnabled || this.value) {
389
+ return;
390
+ }
391
+ this._wrapper = createElement(document, 'div', { class: 'ck ckbox-wrapper' });
392
+ document.body.appendChild(this._wrapper);
393
+ window.CKBox.mount(this._wrapper, this._prepareOptions());
394
+ });
395
+ // Handle closing of the CKBox dialog.
396
+ this.on('ckbox:close', () => {
397
+ if (!this.value) {
398
+ return;
399
+ }
400
+ this._wrapper.remove();
401
+ this._wrapper = null;
402
+ editor.editing.view.focus();
403
+ });
404
+ // Handle choosing the assets.
405
+ this.on('ckbox:choose', (evt, assets) => {
406
+ if (!this.isEnabled) {
407
+ return;
408
+ }
409
+ const imageCommand = editor.commands.get('insertImage');
410
+ const linkCommand = editor.commands.get('link');
411
+ const assetsToProcess = prepareAssets({
412
+ assets,
413
+ isImageAllowed: imageCommand.isEnabled,
414
+ isLinkAllowed: linkCommand.isEnabled
415
+ });
416
+ const assetsCount = assetsToProcess.length;
417
+ if (assetsCount === 0) {
418
+ return;
419
+ }
420
+ // All assets are inserted in one undo step.
421
+ model.change(writer => {
422
+ for (const asset of assetsToProcess) {
423
+ const isLastAsset = asset === assetsToProcess[assetsCount - 1];
424
+ const isSingleAsset = assetsCount === 1;
425
+ this._insertAsset(asset, isLastAsset, writer, isSingleAsset);
426
+ // If asset ID must be set for the inserted model element, store the asset temporarily and remove it automatically
427
+ // after the timeout.
428
+ if (shouldInsertDataId) {
429
+ setTimeout(() => this._chosenAssets.delete(asset), ASSET_INSERTION_WAIT_TIMEOUT);
430
+ this._chosenAssets.add(asset);
431
+ }
432
+ }
433
+ });
434
+ editor.editing.view.focus();
435
+ });
436
+ // Clean up after the editor is destroyed.
437
+ this.listenTo(editor, 'destroy', () => {
438
+ this.fire('ckbox:close');
439
+ this._chosenAssets.clear();
440
+ });
441
+ }
442
+ /**
443
+ * Inserts the asset into the model.
444
+ *
445
+ * @param asset The asset to be inserted.
446
+ * @param isLastAsset Indicates if the current asset is the last one from the chosen set.
447
+ * @param writer An instance of the model writer.
448
+ * @param isSingleAsset It's true when only one asset is processed.
449
+ */
450
+ _insertAsset(asset, isLastAsset, writer, isSingleAsset) {
451
+ const editor = this.editor;
452
+ const model = editor.model;
453
+ const selection = model.document.selection;
454
+ // Remove the `linkHref` attribute to not affect the asset to be inserted.
455
+ writer.removeSelectionAttribute('linkHref');
456
+ if (asset.type === 'image') {
457
+ this._insertImage(asset);
458
+ }
459
+ else {
460
+ this._insertLink(asset, writer, isSingleAsset);
461
+ }
462
+ // Except for the last chosen asset, move the selection to the end of the current range to avoid overwriting other, already
463
+ // inserted assets.
464
+ if (!isLastAsset) {
465
+ writer.setSelection(selection.getLastPosition());
466
+ }
467
+ }
468
+ /**
469
+ * Inserts the image by calling the `insertImage` command.
470
+ *
471
+ * @param asset The asset to be inserted.
472
+ */
473
+ _insertImage(asset) {
474
+ const editor = this.editor;
475
+ const { imageFallbackUrl, imageSources, imageTextAlternative, imageWidth, imageHeight, imagePlaceholder } = asset.attributes;
476
+ editor.execute('insertImage', {
477
+ source: {
478
+ src: imageFallbackUrl,
479
+ sources: imageSources,
480
+ alt: imageTextAlternative,
481
+ width: imageWidth,
482
+ height: imageHeight,
483
+ ...(imagePlaceholder ? { placeholder: imagePlaceholder } : null)
484
+ }
485
+ });
486
+ }
487
+ /**
488
+ * Inserts the link to the asset by calling the `link` command.
489
+ *
490
+ * @param asset The asset to be inserted.
491
+ * @param writer An instance of the model writer.
492
+ * @param isSingleAsset It's true when only one asset is processed.
493
+ */
494
+ _insertLink(asset, writer, isSingleAsset) {
495
+ const editor = this.editor;
496
+ const model = editor.model;
497
+ const selection = model.document.selection;
498
+ const { linkName, linkHref } = asset.attributes;
499
+ // If the selection is collapsed, insert the asset name as the link label and select it.
500
+ if (selection.isCollapsed) {
501
+ const selectionAttributes = toMap(selection.getAttributes());
502
+ const textNode = writer.createText(linkName, selectionAttributes);
503
+ if (!isSingleAsset) {
504
+ const selectionLastPosition = selection.getLastPosition();
505
+ const parentElement = selectionLastPosition.parent;
506
+ // Insert new `paragraph` when selection is not in an empty `paragraph`.
507
+ if (!(parentElement.name === 'paragraph' && parentElement.isEmpty)) {
508
+ editor.execute('insertParagraph', {
509
+ position: selectionLastPosition
510
+ });
511
+ }
512
+ const range = model.insertContent(textNode);
513
+ writer.setSelection(range);
514
+ editor.execute('link', linkHref);
515
+ return;
516
+ }
517
+ const range = model.insertContent(textNode);
518
+ writer.setSelection(range);
519
+ }
520
+ editor.execute('link', linkHref);
521
+ }
522
+ }
523
+ /**
524
+ * Parses the chosen assets into the internal data format. Filters out chosen assets that are not allowed.
525
+ */
526
+ function prepareAssets({ assets, isImageAllowed, isLinkAllowed }) {
527
+ return assets
528
+ .map(asset => isImage(asset) ?
529
+ {
530
+ id: asset.data.id,
531
+ type: 'image',
532
+ attributes: prepareImageAssetAttributes(asset)
533
+ } :
534
+ {
535
+ id: asset.data.id,
536
+ type: 'link',
537
+ attributes: prepareLinkAssetAttributes(asset)
538
+ })
539
+ .filter(asset => asset.type === 'image' ? isImageAllowed : isLinkAllowed);
540
+ }
541
+ /**
542
+ * Parses the assets attributes into the internal data format.
543
+ *
544
+ * @internal
545
+ */
546
+ function prepareImageAssetAttributes(asset) {
547
+ const { imageFallbackUrl, imageSources } = getImageUrls(asset.data.imageUrls);
548
+ const { description, width, height, blurHash } = asset.data.metadata;
549
+ const imagePlaceholder = blurHashToDataUrl(blurHash);
550
+ return {
551
+ imageFallbackUrl,
552
+ imageSources,
553
+ imageTextAlternative: description || '',
554
+ imageWidth: width,
555
+ imageHeight: height,
556
+ ...(imagePlaceholder ? { imagePlaceholder } : null)
557
+ };
558
+ }
559
+ /**
560
+ * Parses the assets attributes into the internal data format.
561
+ *
562
+ * @param origin The base URL for assets inserted into the editor.
563
+ */
564
+ function prepareLinkAssetAttributes(asset) {
565
+ return {
566
+ linkName: asset.data.name,
567
+ linkHref: getAssetUrl(asset)
568
+ };
569
+ }
570
+ /**
571
+ * Checks whether the asset is an image.
572
+ */
573
+ function isImage(asset) {
574
+ const metadata = asset.data.metadata;
575
+ if (!metadata) {
576
+ return false;
577
+ }
578
+ return metadata.width && metadata.height;
579
+ }
580
+ /**
581
+ * Creates the URL for the asset.
582
+ *
583
+ * @param origin The base URL for assets inserted into the editor.
584
+ */
585
+ function getAssetUrl(asset) {
586
+ const url = new URL(asset.data.url);
587
+ url.searchParams.set('download', 'true');
588
+ return url.toString();
589
+ }
590
+
591
+ /**
592
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
593
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
594
+ */
595
+ const DEFAULT_CKBOX_THEME_NAME = 'lark';
596
+ /**
597
+ * The CKBox utilities plugin.
598
+ */
599
+ class CKBoxUtils extends Plugin {
600
+ /**
601
+ * @inheritDoc
602
+ */
603
+ static get pluginName() {
604
+ return 'CKBoxUtils';
605
+ }
606
+ /**
607
+ * @inheritDoc
608
+ */
609
+ static get requires() {
610
+ return ['CloudServices'];
611
+ }
612
+ /**
613
+ * @inheritDoc
614
+ */
615
+ async init() {
616
+ const editor = this.editor;
617
+ const hasConfiguration = !!editor.config.get('ckbox');
618
+ const isLibraryLoaded = !!window.CKBox;
619
+ // Proceed with plugin initialization only when the integrator intentionally wants to use it, i.e. when the `config.ckbox` exists or
620
+ // the CKBox JavaScript library is loaded.
621
+ if (!hasConfiguration && !isLibraryLoaded) {
622
+ return;
623
+ }
624
+ editor.config.define('ckbox', {
625
+ serviceOrigin: 'https://api.ckbox.io',
626
+ defaultUploadCategories: null,
627
+ ignoreDataId: false,
628
+ language: editor.locale.uiLanguage,
629
+ theme: DEFAULT_CKBOX_THEME_NAME,
630
+ tokenUrl: editor.config.get('cloudServices.tokenUrl')
631
+ });
632
+ const cloudServices = editor.plugins.get('CloudServices');
633
+ const cloudServicesTokenUrl = editor.config.get('cloudServices.tokenUrl');
634
+ const ckboxTokenUrl = editor.config.get('ckbox.tokenUrl');
635
+ if (!ckboxTokenUrl) {
636
+ /**
637
+ * The {@link module:ckbox/ckboxconfig~CKBoxConfig#tokenUrl `config.ckbox.tokenUrl`} or the
638
+ * {@link module:cloud-services/cloudservicesconfig~CloudServicesConfig#tokenUrl `config.cloudServices.tokenUrl`}
639
+ * configuration is required for the CKBox plugin.
640
+ *
641
+ * ```ts
642
+ * ClassicEditor.create( document.createElement( 'div' ), {
643
+ * ckbox: {
644
+ * tokenUrl: "YOUR_TOKEN_URL"
645
+ * // ...
646
+ * }
647
+ * // ...
648
+ * } );
649
+ * ```
650
+ *
651
+ * @error ckbox-plugin-missing-token-url
652
+ */
653
+ throw new CKEditorError('ckbox-plugin-missing-token-url', this);
654
+ }
655
+ if (ckboxTokenUrl == cloudServicesTokenUrl) {
656
+ this._token = cloudServices.token;
657
+ }
658
+ else {
659
+ this._token = await cloudServices.registerTokenUrl(ckboxTokenUrl);
660
+ }
661
+ }
662
+ /**
663
+ * Returns a token used by the CKBox plugin for communication with the CKBox service.
664
+ */
665
+ getToken() {
666
+ return this._token;
667
+ }
668
+ /**
669
+ * The ID of workspace to use when uploading an image.
670
+ */
671
+ getWorkspaceId() {
672
+ const t = this.editor.t;
673
+ const cannotAccessDefaultWorkspaceError = t('Cannot access default workspace.');
674
+ const defaultWorkspaceId = this.editor.config.get('ckbox.defaultUploadWorkspaceId');
675
+ const workspaceId = getWorkspaceId(this._token, defaultWorkspaceId);
676
+ if (workspaceId == null) {
677
+ /**
678
+ * The user is not authorized to access the workspace defined in the`ckbox.defaultUploadWorkspaceId` configuration.
679
+ *
680
+ * @error ckbox-access-default-workspace-error
681
+ */
682
+ logError('ckbox-access-default-workspace-error');
683
+ throw cannotAccessDefaultWorkspaceError;
684
+ }
685
+ return workspaceId;
686
+ }
687
+ /**
688
+ * Resolves a promise with an object containing a category with which the uploaded file is associated or an error code.
689
+ */
690
+ async getCategoryIdForFile(fileOrUrl, options) {
691
+ const t = this.editor.t;
692
+ const cannotFindCategoryError = t('Cannot determine a category for the uploaded file.');
693
+ const defaultCategories = this.editor.config.get('ckbox.defaultUploadCategories');
694
+ const allCategoriesPromise = this._getAvailableCategories(options);
695
+ const extension = typeof fileOrUrl == 'string' ?
696
+ convertMimeTypeToExtension(await getContentTypeOfUrl(fileOrUrl, options)) :
697
+ getFileExtension(fileOrUrl);
698
+ const allCategories = await allCategoriesPromise;
699
+ // Couldn't fetch all categories. Perhaps the authorization token is invalid.
700
+ if (!allCategories) {
701
+ throw cannotFindCategoryError;
702
+ }
703
+ // If a user specifies the plugin configuration, find the first category that accepts the uploaded file.
704
+ if (defaultCategories) {
705
+ const userCategory = Object.keys(defaultCategories).find(category => {
706
+ return defaultCategories[category].find(e => e.toLowerCase() == extension);
707
+ });
708
+ // If found, return its ID if the category exists on the server side.
709
+ if (userCategory) {
710
+ const serverCategory = allCategories.find(category => category.id === userCategory || category.name === userCategory);
711
+ if (!serverCategory) {
712
+ throw cannotFindCategoryError;
713
+ }
714
+ return serverCategory.id;
715
+ }
716
+ }
717
+ // Otherwise, find the first category that accepts the uploaded file and returns its ID.
718
+ const category = allCategories.find(category => category.extensions.find(e => e.toLowerCase() == extension));
719
+ if (!category) {
720
+ throw cannotFindCategoryError;
721
+ }
722
+ return category.id;
723
+ }
724
+ /**
725
+ * Resolves a promise with an array containing available categories with which the uploaded file can be associated.
726
+ *
727
+ * If the API returns limited results, the method will collect all items.
728
+ */
729
+ async _getAvailableCategories(options) {
730
+ const ITEMS_PER_REQUEST = 50;
731
+ const editor = this.editor;
732
+ const token = this._token;
733
+ const { signal } = options;
734
+ const serviceOrigin = editor.config.get('ckbox.serviceOrigin');
735
+ const workspaceId = this.getWorkspaceId();
736
+ try {
737
+ const result = [];
738
+ let offset = 0;
739
+ let remainingItems;
740
+ do {
741
+ const data = await fetchCategories(offset);
742
+ result.push(...data.items);
743
+ remainingItems = data.totalCount - (offset + ITEMS_PER_REQUEST);
744
+ offset += ITEMS_PER_REQUEST;
745
+ } while (remainingItems > 0);
746
+ return result;
747
+ }
748
+ catch {
749
+ signal.throwIfAborted();
750
+ /**
751
+ * Fetching a list of available categories with which an uploaded file can be associated failed.
752
+ *
753
+ * @error ckbox-fetch-category-http-error
754
+ */
755
+ logError('ckbox-fetch-category-http-error');
756
+ return undefined;
757
+ }
758
+ function fetchCategories(offset) {
759
+ const categoryUrl = new URL('categories', serviceOrigin);
760
+ categoryUrl.searchParams.set('limit', ITEMS_PER_REQUEST.toString());
761
+ categoryUrl.searchParams.set('offset', offset.toString());
762
+ categoryUrl.searchParams.set('workspaceId', workspaceId);
763
+ return sendHttpRequest({
764
+ url: categoryUrl,
765
+ signal,
766
+ authorization: token.value
767
+ });
768
+ }
769
+ }
770
+ }
771
+
772
+ /**
773
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
774
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
775
+ */
776
+ /* globals AbortController, FormData, URL, window */
777
+ /**
778
+ * @module ckbox/ckboxuploadadapter
779
+ */
780
+ /**
781
+ * A plugin that enables file uploads in CKEditor 5 using the CKBox server–side connector.
782
+ * See the {@glink features/file-management/ckbox CKBox file manager integration} guide to learn how to configure
783
+ * and use this feature as well as find out more about the full integration with the file manager
784
+ * provided by the {@link module:ckbox/ckbox~CKBox} plugin.
785
+ *
786
+ * Check out the {@glink features/images/image-upload/image-upload Image upload overview} guide to learn about
787
+ * other ways to upload images into CKEditor 5.
788
+ */
789
+ class CKBoxUploadAdapter extends Plugin {
790
+ /**
791
+ * @inheritDoc
792
+ */
793
+ static get requires() {
794
+ return ['ImageUploadEditing', 'ImageUploadProgress', FileRepository, CKBoxEditing];
795
+ }
796
+ /**
797
+ * @inheritDoc
798
+ */
799
+ static get pluginName() {
800
+ return 'CKBoxUploadAdapter';
801
+ }
802
+ /**
803
+ * @inheritDoc
804
+ */
805
+ async afterInit() {
806
+ const editor = this.editor;
807
+ const hasConfiguration = !!editor.config.get('ckbox');
808
+ const isLibraryLoaded = !!window.CKBox;
809
+ // Editor supports only one upload adapter. Register the CKBox upload adapter (and potentially overwrite other one) only when the
810
+ // integrator intentionally wants to use the CKBox plugin, i.e. when the `config.ckbox` exists or the CKBox JavaScript library is
811
+ // loaded.
812
+ if (!hasConfiguration && !isLibraryLoaded) {
813
+ return;
814
+ }
815
+ const fileRepository = editor.plugins.get(FileRepository);
816
+ const ckboxUtils = editor.plugins.get(CKBoxUtils);
817
+ fileRepository.createUploadAdapter = loader => new Adapter(loader, editor, ckboxUtils);
818
+ const shouldInsertDataId = !editor.config.get('ckbox.ignoreDataId');
819
+ const imageUploadEditing = editor.plugins.get('ImageUploadEditing');
820
+ // Mark uploaded assets with the `ckboxImageId` attribute. Its value represents an ID in CKBox.
821
+ if (shouldInsertDataId) {
822
+ imageUploadEditing.on('uploadComplete', (evt, { imageElement, data }) => {
823
+ editor.model.change(writer => {
824
+ writer.setAttribute('ckboxImageId', data.ckboxImageId, imageElement);
825
+ });
826
+ });
827
+ }
828
+ }
829
+ }
830
+ /**
831
+ * Upload adapter for CKBox.
832
+ */
833
+ class Adapter {
834
+ /**
835
+ * Creates a new adapter instance.
836
+ */
837
+ constructor(loader, editor, ckboxUtils) {
838
+ this.loader = loader;
839
+ this.token = ckboxUtils.getToken();
840
+ this.ckboxUtils = ckboxUtils;
841
+ this.editor = editor;
842
+ this.controller = new AbortController();
843
+ this.serviceOrigin = editor.config.get('ckbox.serviceOrigin');
844
+ }
845
+ /**
846
+ * Starts the upload process.
847
+ *
848
+ * @see module:upload/filerepository~UploadAdapter#upload
849
+ */
850
+ async upload() {
851
+ const ckboxUtils = this.ckboxUtils;
852
+ const t = this.editor.t;
853
+ const file = (await this.loader.file);
854
+ const category = await ckboxUtils.getCategoryIdForFile(file, { signal: this.controller.signal });
855
+ const uploadUrl = new URL('assets', this.serviceOrigin);
856
+ const formData = new FormData();
857
+ uploadUrl.searchParams.set('workspaceId', ckboxUtils.getWorkspaceId());
858
+ formData.append('categoryId', category);
859
+ formData.append('file', file);
860
+ const requestConfig = {
861
+ method: 'POST',
862
+ url: uploadUrl,
863
+ data: formData,
864
+ onUploadProgress: (evt) => {
865
+ /* istanbul ignore else -- @preserve */
866
+ if (evt.lengthComputable) {
867
+ this.loader.uploadTotal = evt.total;
868
+ this.loader.uploaded = evt.loaded;
869
+ }
870
+ },
871
+ signal: this.controller.signal,
872
+ authorization: this.token.value
873
+ };
874
+ return sendHttpRequest(requestConfig)
875
+ .then(async (data) => {
876
+ const imageUrls = getImageUrls(data.imageUrls);
877
+ return {
878
+ ckboxImageId: data.id,
879
+ default: imageUrls.imageFallbackUrl,
880
+ sources: imageUrls.imageSources
881
+ };
882
+ })
883
+ .catch(() => {
884
+ const genericError = t('Cannot upload file:') + ` ${file.name}.`;
885
+ return Promise.reject(genericError);
886
+ });
887
+ }
888
+ /**
889
+ * Aborts the upload process.
890
+ *
891
+ * @see module:upload/filerepository~UploadAdapter#abort
892
+ */
893
+ abort() {
894
+ this.controller.abort();
895
+ }
896
+ }
897
+
898
+ /**
899
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
900
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
901
+ */
902
+ /**
903
+ * The CKBox editing feature. It introduces the {@link module:ckbox/ckboxcommand~CKBoxCommand CKBox command} and
904
+ * {@link module:ckbox/ckboxuploadadapter~CKBoxUploadAdapter CKBox upload adapter}.
905
+ */
906
+ class CKBoxEditing extends Plugin {
907
+ /**
908
+ * @inheritDoc
909
+ */
910
+ static get pluginName() {
911
+ return 'CKBoxEditing';
912
+ }
913
+ /**
914
+ * @inheritDoc
915
+ */
916
+ static get requires() {
917
+ return ['LinkEditing', 'PictureEditing', CKBoxUploadAdapter, CKBoxUtils];
918
+ }
919
+ /**
920
+ * @inheritDoc
921
+ */
922
+ init() {
923
+ const editor = this.editor;
924
+ if (!this._shouldBeInitialised()) {
925
+ return;
926
+ }
927
+ this._checkImagePlugins();
928
+ // Registering the `ckbox` command makes sense only if the CKBox library is loaded, as the `ckbox` command opens the CKBox dialog.
929
+ if (isLibraryLoaded()) {
930
+ editor.commands.add('ckbox', new CKBoxCommand(editor));
931
+ }
932
+ }
933
+ /**
934
+ * @inheritDoc
935
+ */
936
+ afterInit() {
937
+ const editor = this.editor;
938
+ if (!this._shouldBeInitialised()) {
939
+ return;
940
+ }
941
+ // Extending the schema, registering converters and applying fixers only make sense if the configuration option to assign
942
+ // the assets ID with the model elements is enabled.
943
+ if (!editor.config.get('ckbox.ignoreDataId')) {
944
+ this._initSchema();
945
+ this._initConversion();
946
+ this._initFixers();
947
+ }
948
+ }
949
+ /**
950
+ * Returns true only when the integrator intentionally wants to use the plugin, i.e. when the `config.ckbox` exists or
951
+ * the CKBox JavaScript library is loaded.
952
+ */
953
+ _shouldBeInitialised() {
954
+ const editor = this.editor;
955
+ const hasConfiguration = !!editor.config.get('ckbox');
956
+ return hasConfiguration || isLibraryLoaded();
957
+ }
958
+ /**
959
+ * Checks if at least one image plugin is loaded.
960
+ */
961
+ _checkImagePlugins() {
962
+ const editor = this.editor;
963
+ if (!editor.plugins.has('ImageBlockEditing') && !editor.plugins.has('ImageInlineEditing')) {
964
+ /**
965
+ * The CKBox feature requires one of the following plugins to be loaded to work correctly:
966
+ *
967
+ * * {@link module:image/imageblock~ImageBlock},
968
+ * * {@link module:image/imageinline~ImageInline},
969
+ * * {@link module:image/image~Image} (loads both `ImageBlock` and `ImageInline`)
970
+ *
971
+ * Please make sure your editor configuration is correct.
972
+ *
973
+ * @error ckbox-plugin-image-feature-missing
974
+ * @param {module:core/editor/editor~Editor} editor
975
+ */
976
+ logError('ckbox-plugin-image-feature-missing', editor);
977
+ }
978
+ }
979
+ /**
980
+ * Extends the schema to allow the `ckboxImageId` and `ckboxLinkId` attributes for links and images.
981
+ */
982
+ _initSchema() {
983
+ const editor = this.editor;
984
+ const schema = editor.model.schema;
985
+ schema.extend('$text', { allowAttributes: 'ckboxLinkId' });
986
+ if (schema.isRegistered('imageBlock')) {
987
+ schema.extend('imageBlock', { allowAttributes: ['ckboxImageId', 'ckboxLinkId'] });
988
+ }
989
+ if (schema.isRegistered('imageInline')) {
990
+ schema.extend('imageInline', { allowAttributes: ['ckboxImageId', 'ckboxLinkId'] });
991
+ }
992
+ schema.addAttributeCheck((context, attributeName) => {
993
+ const isLink = !!context.last.getAttribute('linkHref');
994
+ if (!isLink && attributeName === 'ckboxLinkId') {
995
+ return false;
996
+ }
997
+ });
998
+ }
999
+ /**
1000
+ * Configures the upcast and downcast conversions for the `ckboxImageId` and `ckboxLinkId` attributes.
1001
+ */
1002
+ _initConversion() {
1003
+ const editor = this.editor;
1004
+ // Convert `ckboxLinkId` => `data-ckbox-resource-id`.
1005
+ editor.conversion.for('downcast').add(dispatcher => {
1006
+ // Due to custom converters for linked block images, handle the `ckboxLinkId` attribute manually.
1007
+ dispatcher.on('attribute:ckboxLinkId:imageBlock', (evt, data, conversionApi) => {
1008
+ const { writer, mapper, consumable } = conversionApi;
1009
+ if (!consumable.consume(data.item, evt.name)) {
1010
+ return;
1011
+ }
1012
+ const viewFigure = mapper.toViewElement(data.item);
1013
+ const linkInImage = [...viewFigure.getChildren()]
1014
+ .find((child) => child.name === 'a');
1015
+ // No link inside an image - no conversion needed.
1016
+ if (!linkInImage) {
1017
+ return;
1018
+ }
1019
+ if (data.item.hasAttribute('ckboxLinkId')) {
1020
+ writer.setAttribute('data-ckbox-resource-id', data.item.getAttribute('ckboxLinkId'), linkInImage);
1021
+ }
1022
+ else {
1023
+ writer.removeAttribute('data-ckbox-resource-id', linkInImage);
1024
+ }
1025
+ }, { priority: 'low' });
1026
+ dispatcher.on('attribute:ckboxLinkId', (evt, data, conversionApi) => {
1027
+ const { writer, mapper, consumable } = conversionApi;
1028
+ if (!consumable.consume(data.item, evt.name)) {
1029
+ return;
1030
+ }
1031
+ // Remove the previous attribute value if it was applied.
1032
+ if (data.attributeOldValue) {
1033
+ const viewElement = createLinkElement(writer, data.attributeOldValue);
1034
+ writer.unwrap(mapper.toViewRange(data.range), viewElement);
1035
+ }
1036
+ // Add the new attribute value if specified in a model element.
1037
+ if (data.attributeNewValue) {
1038
+ const viewElement = createLinkElement(writer, data.attributeNewValue);
1039
+ if (data.item.is('selection')) {
1040
+ const viewSelection = writer.document.selection;
1041
+ writer.wrap(viewSelection.getFirstRange(), viewElement);
1042
+ }
1043
+ else {
1044
+ writer.wrap(mapper.toViewRange(data.range), viewElement);
1045
+ }
1046
+ }
1047
+ }, { priority: 'low' });
1048
+ });
1049
+ // Convert `data-ckbox-resource-id` => `ckboxLinkId`.
1050
+ //
1051
+ // The helper conversion does not handle all cases, so take care of the `data-ckbox-resource-id` attribute manually for images
1052
+ // and links.
1053
+ editor.conversion.for('upcast').add(dispatcher => {
1054
+ dispatcher.on('element:a', (evt, data, conversionApi) => {
1055
+ const { writer, consumable } = conversionApi;
1056
+ // Upcast the `data-ckbox-resource-id` attribute only for valid link elements.
1057
+ if (!data.viewItem.getAttribute('href')) {
1058
+ return;
1059
+ }
1060
+ const consumableAttributes = { attributes: ['data-ckbox-resource-id'] };
1061
+ if (!consumable.consume(data.viewItem, consumableAttributes)) {
1062
+ return;
1063
+ }
1064
+ const attributeValue = data.viewItem.getAttribute('data-ckbox-resource-id');
1065
+ // Missing the `data-ckbox-resource-id` attribute.
1066
+ if (!attributeValue) {
1067
+ return;
1068
+ }
1069
+ if (data.modelRange) {
1070
+ // If the `<a>` element contains more than single children (e.g. a linked image), set the `ckboxLinkId` for each
1071
+ // allowed child.
1072
+ for (let item of data.modelRange.getItems()) {
1073
+ if (item.is('$textProxy')) {
1074
+ item = item.textNode;
1075
+ }
1076
+ // Do not copy the `ckboxLinkId` attribute when wrapping an element in a block element, e.g. when
1077
+ // auto-paragraphing.
1078
+ if (shouldUpcastAttributeForNode(item)) {
1079
+ writer.setAttribute('ckboxLinkId', attributeValue, item);
1080
+ }
1081
+ }
1082
+ }
1083
+ else {
1084
+ // Otherwise, just set the `ckboxLinkId` for the model element.
1085
+ const modelElement = data.modelCursor.nodeBefore || data.modelCursor.parent;
1086
+ writer.setAttribute('ckboxLinkId', attributeValue, modelElement);
1087
+ }
1088
+ }, { priority: 'low' });
1089
+ });
1090
+ // Convert `ckboxImageId` => `data-ckbox-resource-id`.
1091
+ editor.conversion.for('downcast').attributeToAttribute({
1092
+ model: 'ckboxImageId',
1093
+ view: 'data-ckbox-resource-id'
1094
+ });
1095
+ // Convert `data-ckbox-resource-id` => `ckboxImageId`.
1096
+ editor.conversion.for('upcast').elementToAttribute({
1097
+ model: {
1098
+ key: 'ckboxImageId',
1099
+ value: (viewElement) => viewElement.getAttribute('data-ckbox-resource-id')
1100
+ },
1101
+ view: {
1102
+ attributes: {
1103
+ 'data-ckbox-resource-id': /[\s\S]+/
1104
+ }
1105
+ }
1106
+ });
1107
+ const replaceImageSourceCommand = editor.commands.get('replaceImageSource');
1108
+ if (replaceImageSourceCommand) {
1109
+ this.listenTo(replaceImageSourceCommand, 'cleanupImage', (_, [writer, image]) => {
1110
+ writer.removeAttribute('ckboxImageId', image);
1111
+ });
1112
+ }
1113
+ }
1114
+ /**
1115
+ * Registers post-fixers that add or remove the `ckboxLinkId` and `ckboxImageId` attributes.
1116
+ */
1117
+ _initFixers() {
1118
+ const editor = this.editor;
1119
+ const model = editor.model;
1120
+ const selection = model.document.selection;
1121
+ // Registers the post-fixer to sync the asset ID with the model elements.
1122
+ model.document.registerPostFixer(syncDataIdPostFixer(editor));
1123
+ // Registers the post-fixer to remove the `ckboxLinkId` attribute from the model selection.
1124
+ model.document.registerPostFixer(injectSelectionPostFixer(selection));
1125
+ }
1126
+ }
1127
+ /**
1128
+ * A post-fixer that synchronizes the asset ID with the model element.
1129
+ */
1130
+ function syncDataIdPostFixer(editor) {
1131
+ return (writer) => {
1132
+ let changed = false;
1133
+ const model = editor.model;
1134
+ const ckboxCommand = editor.commands.get('ckbox');
1135
+ // The ID from chosen assets are stored in the `CKBoxCommand#_chosenAssets`. If there is no command, it makes no sense to check
1136
+ // for changes in the model.
1137
+ if (!ckboxCommand) {
1138
+ return changed;
1139
+ }
1140
+ for (const entry of model.document.differ.getChanges()) {
1141
+ if (entry.type !== 'insert' && entry.type !== 'attribute') {
1142
+ continue;
1143
+ }
1144
+ const range = entry.type === 'insert' ?
1145
+ new Range(entry.position, entry.position.getShiftedBy(entry.length)) :
1146
+ entry.range;
1147
+ const isLinkHrefAttributeRemoval = entry.type === 'attribute' &&
1148
+ entry.attributeKey === 'linkHref' &&
1149
+ entry.attributeNewValue === null;
1150
+ for (const item of range.getItems()) {
1151
+ // If the `linkHref` attribute has been removed, sync the change with the `ckboxLinkId` attribute.
1152
+ if (isLinkHrefAttributeRemoval && item.hasAttribute('ckboxLinkId')) {
1153
+ writer.removeAttribute('ckboxLinkId', item);
1154
+ changed = true;
1155
+ continue;
1156
+ }
1157
+ // Otherwise, the change concerns either a new model element or an attribute change. Try to find the assets for the modified
1158
+ // model element.
1159
+ const assets = findAssetsForItem(item, ckboxCommand._chosenAssets);
1160
+ for (const asset of assets) {
1161
+ const attributeName = asset.type === 'image' ? 'ckboxImageId' : 'ckboxLinkId';
1162
+ if (asset.id === item.getAttribute(attributeName)) {
1163
+ continue;
1164
+ }
1165
+ writer.setAttribute(attributeName, asset.id, item);
1166
+ changed = true;
1167
+ }
1168
+ }
1169
+ }
1170
+ return changed;
1171
+ };
1172
+ }
1173
+ /**
1174
+ * A post-fixer that removes the `ckboxLinkId` from the selection if it does not represent a link anymore.
1175
+ */
1176
+ function injectSelectionPostFixer(selection) {
1177
+ return (writer) => {
1178
+ const shouldRemoveLinkIdAttribute = !selection.hasAttribute('linkHref') && selection.hasAttribute('ckboxLinkId');
1179
+ if (shouldRemoveLinkIdAttribute) {
1180
+ writer.removeSelectionAttribute('ckboxLinkId');
1181
+ return true;
1182
+ }
1183
+ return false;
1184
+ };
1185
+ }
1186
+ /**
1187
+ * Tries to find the asset that is associated with the model element by comparing the attributes:
1188
+ * - the image fallback URL with the `src` attribute for images,
1189
+ * - the link URL with the `href` attribute for links.
1190
+ *
1191
+ * For any model element, zero, one or more than one asset can be found (e.g. a linked image may be associated with the link asset and the
1192
+ * image asset).
1193
+ */
1194
+ function findAssetsForItem(item, assets) {
1195
+ const isImageElement = item.is('element', 'imageInline') || item.is('element', 'imageBlock');
1196
+ const isLinkElement = item.hasAttribute('linkHref');
1197
+ return [...assets].filter(asset => {
1198
+ if (asset.type === 'image' && isImageElement) {
1199
+ return asset.attributes.imageFallbackUrl === item.getAttribute('src');
1200
+ }
1201
+ if (asset.type === 'link' && isLinkElement) {
1202
+ return asset.attributes.linkHref === item.getAttribute('linkHref');
1203
+ }
1204
+ });
1205
+ }
1206
+ /**
1207
+ * Creates view link element with the requested ID.
1208
+ */
1209
+ function createLinkElement(writer, id) {
1210
+ // Priority equal 5 is needed to merge adjacent `<a>` elements together.
1211
+ const viewElement = writer.createAttributeElement('a', { 'data-ckbox-resource-id': id }, { priority: 5 });
1212
+ writer.setCustomProperty('link', true, viewElement);
1213
+ return viewElement;
1214
+ }
1215
+ /**
1216
+ * Checks if the model element may have the `ckboxLinkId` attribute.
1217
+ */
1218
+ function shouldUpcastAttributeForNode(node) {
1219
+ if (node.is('$text')) {
1220
+ return true;
1221
+ }
1222
+ if (node.is('element', 'imageInline') || node.is('element', 'imageBlock')) {
1223
+ return true;
1224
+ }
1225
+ return false;
1226
+ }
1227
+ /**
1228
+ * Returns true if the CKBox library is loaded, false otherwise.
1229
+ */
1230
+ function isLibraryLoaded() {
1231
+ return !!window.CKBox;
1232
+ }
1233
+
1234
+ /**
1235
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
1236
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
1237
+ */
1238
+ /**
1239
+ * @module ckbox/ckbox
1240
+ */
1241
+ /**
1242
+ * The CKBox feature, a bridge between the CKEditor 5 WYSIWYG editor and the CKBox file manager and uploader.
1243
+ *
1244
+ * This is a "glue" plugin which enables:
1245
+ *
1246
+ * * {@link module:ckbox/ckboxediting~CKBoxEditing},
1247
+ * * {@link module:ckbox/ckboxui~CKBoxUI},
1248
+ *
1249
+ * See the {@glink features/file-management/ckbox CKBox integration} guide to learn how to configure and use this feature.
1250
+ *
1251
+ * Check out the {@glink features/images/image-upload/image-upload Image upload} guide to learn about other ways to upload
1252
+ * images into CKEditor 5.
1253
+ */
1254
+ class CKBox extends Plugin {
1255
+ /**
1256
+ * @inheritDoc
1257
+ */
1258
+ static get pluginName() {
1259
+ return 'CKBox';
1260
+ }
1261
+ /**
1262
+ * @inheritDoc
1263
+ */
1264
+ static get requires() {
1265
+ return [CKBoxEditing, CKBoxUI];
1266
+ }
1267
+ }
1268
+
1269
+ /**
1270
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
1271
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
1272
+ */
1273
+ /**
1274
+ * @module ckbox/ckboximageedit/utils
1275
+ */
1276
+ /**
1277
+ * @internal
1278
+ */
1279
+ function createEditabilityChecker(allowExternalImagesEditing) {
1280
+ const checkUrl = createUrlChecker(allowExternalImagesEditing);
1281
+ return element => {
1282
+ const isImageElement = element.is('element', 'imageInline') ||
1283
+ element.is('element', 'imageBlock');
1284
+ if (!isImageElement) {
1285
+ return false;
1286
+ }
1287
+ if (element.hasAttribute('ckboxImageId')) {
1288
+ return true;
1289
+ }
1290
+ if (element.hasAttribute('src')) {
1291
+ return checkUrl(element.getAttribute('src'));
1292
+ }
1293
+ return false;
1294
+ };
1295
+ }
1296
+ function createUrlChecker(allowExternalImagesEditing) {
1297
+ if (Array.isArray(allowExternalImagesEditing)) {
1298
+ const urlMatchers = allowExternalImagesEditing.map(createUrlChecker);
1299
+ return src => urlMatchers.some(matcher => matcher(src));
1300
+ }
1301
+ if (allowExternalImagesEditing == 'origin') {
1302
+ const origin = global.window.location.origin;
1303
+ return src => new URL(src, global.document.baseURI).origin == origin;
1304
+ }
1305
+ if (typeof allowExternalImagesEditing == 'function') {
1306
+ return allowExternalImagesEditing;
1307
+ }
1308
+ if (allowExternalImagesEditing instanceof RegExp) {
1309
+ return src => !!(src.match(allowExternalImagesEditing) ||
1310
+ src.replace(/^https?:\/\//, '').match(allowExternalImagesEditing));
1311
+ }
1312
+ return () => false;
1313
+ }
1314
+
1315
+ /**
1316
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
1317
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
1318
+ */
1319
+ /* globals document, console, AbortController, URL, window */
1320
+ /**
1321
+ * @module ckbox/ckboximageedit/ckboximageeditcommand
1322
+ */
1323
+ /**
1324
+ * The CKBox edit image command.
1325
+ *
1326
+ * Opens the CKBox dialog for editing the image.
1327
+ */
1328
+ class CKBoxImageEditCommand extends Command {
1329
+ /**
1330
+ * @inheritDoc
1331
+ */
1332
+ constructor(editor) {
1333
+ super(editor);
1334
+ /**
1335
+ * The DOM element that acts as a mounting point for the CKBox Edit Image dialog.
1336
+ */
1337
+ this._wrapper = null;
1338
+ /**
1339
+ * The states of image processing in progress.
1340
+ */
1341
+ this._processInProgress = new Set();
1342
+ this.value = false;
1343
+ this._canEdit = createEditabilityChecker(editor.config.get('ckbox.allowExternalImagesEditing'));
1344
+ this._prepareOptions = abortableDebounce((signal, state) => this._prepareOptionsAbortable(signal, state));
1345
+ this._prepareListeners();
1346
+ }
1347
+ /**
1348
+ * @inheritDoc
1349
+ */
1350
+ refresh() {
1351
+ const editor = this.editor;
1352
+ this.value = this._getValue();
1353
+ const selectedElement = editor.model.document.selection.getSelectedElement();
1354
+ this.isEnabled =
1355
+ !!selectedElement &&
1356
+ this._canEdit(selectedElement) &&
1357
+ !this._checkIfElementIsBeingProcessed(selectedElement);
1358
+ }
1359
+ /**
1360
+ * Opens the CKBox Image Editor dialog for editing the image.
1361
+ */
1362
+ execute() {
1363
+ if (this._getValue()) {
1364
+ return;
1365
+ }
1366
+ const wrapper = createElement(document, 'div', { class: 'ck ckbox-wrapper' });
1367
+ this._wrapper = wrapper;
1368
+ this.value = true;
1369
+ document.body.appendChild(this._wrapper);
1370
+ const imageElement = this.editor.model.document.selection.getSelectedElement();
1371
+ const processingState = {
1372
+ element: imageElement,
1373
+ controller: new AbortController()
1374
+ };
1375
+ this._prepareOptions(processingState).then(options => window.CKBox.mountImageEditor(wrapper, options), error => {
1376
+ const editor = this.editor;
1377
+ const t = editor.t;
1378
+ const notification = editor.plugins.get(Notification);
1379
+ notification.showWarning(t('Failed to determine category of edited image.'), {
1380
+ namespace: 'ckbox'
1381
+ });
1382
+ console.error(error);
1383
+ this._handleImageEditorClose();
1384
+ });
1385
+ }
1386
+ /**
1387
+ * @inheritDoc
1388
+ */
1389
+ destroy() {
1390
+ this._handleImageEditorClose();
1391
+ this._prepareOptions.abort();
1392
+ for (const state of this._processInProgress.values()) {
1393
+ state.controller.abort();
1394
+ }
1395
+ super.destroy();
1396
+ }
1397
+ /**
1398
+ * Indicates if the CKBox Image Editor dialog is already opened.
1399
+ */
1400
+ _getValue() {
1401
+ return this._wrapper !== null;
1402
+ }
1403
+ /**
1404
+ * Creates the options object for the CKBox Image Editor dialog.
1405
+ */
1406
+ async _prepareOptionsAbortable(signal, state) {
1407
+ const editor = this.editor;
1408
+ const ckboxConfig = editor.config.get('ckbox');
1409
+ const ckboxUtils = editor.plugins.get(CKBoxUtils);
1410
+ const { element } = state;
1411
+ let imageMountOptions;
1412
+ const ckboxImageId = element.getAttribute('ckboxImageId');
1413
+ if (ckboxImageId) {
1414
+ imageMountOptions = {
1415
+ assetId: ckboxImageId
1416
+ };
1417
+ }
1418
+ else {
1419
+ const imageUrl = new URL(element.getAttribute('src'), document.baseURI).href;
1420
+ const uploadCategoryId = await ckboxUtils.getCategoryIdForFile(imageUrl, { signal });
1421
+ imageMountOptions = {
1422
+ imageUrl,
1423
+ uploadCategoryId
1424
+ };
1425
+ }
1426
+ return {
1427
+ ...imageMountOptions,
1428
+ imageEditing: {
1429
+ allowOverwrite: false
1430
+ },
1431
+ tokenUrl: ckboxConfig.tokenUrl,
1432
+ ...(ckboxConfig.serviceOrigin && { serviceOrigin: ckboxConfig.serviceOrigin }),
1433
+ onClose: () => this._handleImageEditorClose(),
1434
+ onSave: (asset) => this._handleImageEditorSave(state, asset)
1435
+ };
1436
+ }
1437
+ /**
1438
+ * Initializes event lister for an event of removing an image.
1439
+ */
1440
+ _prepareListeners() {
1441
+ // Abort editing processing when the image has been removed.
1442
+ this.listenTo(this.editor.model.document, 'change:data', () => {
1443
+ const processingStates = this._getProcessingStatesOfDeletedImages();
1444
+ processingStates.forEach(processingState => {
1445
+ processingState.controller.abort();
1446
+ });
1447
+ });
1448
+ }
1449
+ /**
1450
+ * Gets processing states of images that have been deleted in the mean time.
1451
+ */
1452
+ _getProcessingStatesOfDeletedImages() {
1453
+ const states = [];
1454
+ for (const state of this._processInProgress.values()) {
1455
+ if (state.element.root.rootName == '$graveyard') {
1456
+ states.push(state);
1457
+ }
1458
+ }
1459
+ return states;
1460
+ }
1461
+ _checkIfElementIsBeingProcessed(selectedElement) {
1462
+ for (const { element } of this._processInProgress) {
1463
+ if (isEqual(element, selectedElement)) {
1464
+ return true;
1465
+ }
1466
+ }
1467
+ return false;
1468
+ }
1469
+ /**
1470
+ * Closes the CKBox Image Editor dialog.
1471
+ */
1472
+ _handleImageEditorClose() {
1473
+ if (!this._wrapper) {
1474
+ return;
1475
+ }
1476
+ this._wrapper.remove();
1477
+ this._wrapper = null;
1478
+ this.editor.editing.view.focus();
1479
+ this.refresh();
1480
+ }
1481
+ /**
1482
+ * Save edited image. In case server respond with "success" replace with edited image,
1483
+ * otherwise show notification error.
1484
+ */
1485
+ _handleImageEditorSave(state, asset) {
1486
+ const t = this.editor.locale.t;
1487
+ const notification = this.editor.plugins.get(Notification);
1488
+ const pendingActions = this.editor.plugins.get(PendingActions);
1489
+ const action = pendingActions.add(t('Processing the edited image.'));
1490
+ this._processInProgress.add(state);
1491
+ this._showImageProcessingIndicator(state.element, asset);
1492
+ this.refresh();
1493
+ this._waitForAssetProcessed(asset.data.id, state.controller.signal)
1494
+ .then(asset => {
1495
+ this._replaceImage(state.element, asset);
1496
+ }, error => {
1497
+ // Remove processing indicator. It was added only to ViewElement.
1498
+ this.editor.editing.reconvertItem(state.element);
1499
+ if (state.controller.signal.aborted) {
1500
+ return;
1501
+ }
1502
+ if (!error || error instanceof CKEditorError) {
1503
+ notification.showWarning(t('Server failed to process the image.'), {
1504
+ namespace: 'ckbox'
1505
+ });
1506
+ }
1507
+ else {
1508
+ console.error(error);
1509
+ }
1510
+ }).finally(() => {
1511
+ this._processInProgress.delete(state);
1512
+ pendingActions.remove(action);
1513
+ this.refresh();
1514
+ });
1515
+ }
1516
+ /**
1517
+ * Get asset's status on server. If server responds with "success" status then
1518
+ * image is already proceeded and ready for saving.
1519
+ */
1520
+ async _getAssetStatusFromServer(id, signal) {
1521
+ const ckboxUtils = this.editor.plugins.get(CKBoxUtils);
1522
+ const url = new URL('assets/' + id, this.editor.config.get('ckbox.serviceOrigin'));
1523
+ const response = await sendHttpRequest({
1524
+ url,
1525
+ signal,
1526
+ authorization: ckboxUtils.getToken().value
1527
+ });
1528
+ const status = response.metadata.metadataProcessingStatus;
1529
+ if (!status || status == 'queued') {
1530
+ /**
1531
+ * Image has not been processed yet.
1532
+ *
1533
+ * @error ckbox-image-not-processed
1534
+ */
1535
+ throw new CKEditorError('ckbox-image-not-processed');
1536
+ }
1537
+ return { data: { ...response } };
1538
+ }
1539
+ /**
1540
+ * Waits for an asset to be processed.
1541
+ * It retries retrieving asset status from the server in case of failure.
1542
+ */
1543
+ async _waitForAssetProcessed(id, signal) {
1544
+ const result = await retry(() => this._getAssetStatusFromServer(id, signal), {
1545
+ signal,
1546
+ maxAttempts: 5
1547
+ });
1548
+ if (result.data.metadata.metadataProcessingStatus != 'success') {
1549
+ /**
1550
+ * The image processing failed.
1551
+ *
1552
+ * @error ckbox-image-processing-failed
1553
+ */
1554
+ throw new CKEditorError('ckbox-image-processing-failed');
1555
+ }
1556
+ return result;
1557
+ }
1558
+ /**
1559
+ * Shows processing indicator while image is processing.
1560
+ *
1561
+ * @param asset Data about certain asset.
1562
+ */
1563
+ _showImageProcessingIndicator(element, asset) {
1564
+ const editor = this.editor;
1565
+ editor.editing.view.change(writer => {
1566
+ const imageElementView = editor.editing.mapper.toViewElement(element);
1567
+ const imageUtils = this.editor.plugins.get('ImageUtils');
1568
+ const img = imageUtils.findViewImgElement(imageElementView);
1569
+ writer.removeStyle('aspect-ratio', img);
1570
+ writer.setAttribute('width', asset.data.metadata.width, img);
1571
+ writer.setAttribute('height', asset.data.metadata.height, img);
1572
+ writer.setStyle('width', `${asset.data.metadata.width}px`, img);
1573
+ writer.setStyle('height', `${asset.data.metadata.height}px`, img);
1574
+ writer.addClass('image-processing', imageElementView);
1575
+ });
1576
+ }
1577
+ /**
1578
+ * Replace the edited image with the new one.
1579
+ */
1580
+ _replaceImage(element, asset) {
1581
+ const editor = this.editor;
1582
+ const { imageFallbackUrl, imageSources, imageWidth, imageHeight, imagePlaceholder } = prepareImageAssetAttributes(asset);
1583
+ const previousSelectionRanges = Array.from(editor.model.document.selection.getRanges());
1584
+ editor.model.change(writer => {
1585
+ writer.setSelection(element, 'on');
1586
+ editor.execute('insertImage', {
1587
+ source: {
1588
+ src: imageFallbackUrl,
1589
+ sources: imageSources,
1590
+ width: imageWidth,
1591
+ height: imageHeight,
1592
+ ...(imagePlaceholder ? { placeholder: imagePlaceholder } : null),
1593
+ ...(element.hasAttribute('alt') ? { alt: element.getAttribute('alt') } : null)
1594
+ }
1595
+ });
1596
+ const previousChildren = element.getChildren();
1597
+ element = editor.model.document.selection.getSelectedElement();
1598
+ for (const child of previousChildren) {
1599
+ writer.append(writer.cloneElement(child), element);
1600
+ }
1601
+ writer.setAttribute('ckboxImageId', asset.data.id, element);
1602
+ writer.setSelection(previousSelectionRanges);
1603
+ });
1604
+ }
1605
+ }
1606
+
1607
+ /**
1608
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
1609
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
1610
+ */
1611
+ /**
1612
+ * @module ckbox/ckboximageedit/ckboximageeditediting
1613
+ */
1614
+ /**
1615
+ * The CKBox image edit editing plugin.
1616
+ */
1617
+ class CKBoxImageEditEditing extends Plugin {
1618
+ /**
1619
+ * @inheritDoc
1620
+ */
1621
+ static get pluginName() {
1622
+ return 'CKBoxImageEditEditing';
1623
+ }
1624
+ /**
1625
+ * @inheritDoc
1626
+ */
1627
+ static get requires() {
1628
+ return [CKBoxEditing, CKBoxUtils, PendingActions, Notification, 'ImageUtils', 'ImageEditing'];
1629
+ }
1630
+ /**
1631
+ * @inheritDoc
1632
+ */
1633
+ init() {
1634
+ const { editor } = this;
1635
+ editor.commands.add('ckboxImageEdit', new CKBoxImageEditCommand(editor));
1636
+ }
1637
+ }
1638
+
1639
+ var ckboxImageEditIcon = "<svg viewBox=\"0 0 20 20\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M1.201 1C.538 1 0 1.47 0 2.1v14.363c0 .64.534 1.037 1.186 1.037H5.06l5.058-5.078L6.617 9.15a.696.696 0 0 0-.957-.033L1.5 13.6V2.5h15v4.354a3.478 3.478 0 0 1 1.5.049V2.1c0-.63-.547-1.1-1.2-1.1H1.202Zm11.713 2.803a2.147 2.147 0 0 0-2.049 1.992 2.14 2.14 0 0 0 1.28 2.096 2.13 2.13 0 0 0 2.642-3.11 2.129 2.129 0 0 0-1.873-.978ZM8.089 17.635v2.388h2.389l7.046-7.046-2.39-2.39-7.045 7.048Zm11.282-6.507a.637.637 0 0 0 .139-.692.603.603 0 0 0-.139-.205l-1.49-1.488a.63.63 0 0 0-.899 0l-1.166 1.163 2.39 2.39 1.165-1.168Z\"/></svg>";
1640
+
1641
+ /**
1642
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
1643
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
1644
+ */
1645
+ /**
1646
+ * @module ckbox/ckboximageedit/ckboximageeditui
1647
+ */
1648
+ /**
1649
+ * The UI plugin of the CKBox image edit feature.
1650
+ *
1651
+ * It registers the `'ckboxImageEdit'` UI button in the editor's {@link module:ui/componentfactory~ComponentFactory component factory}
1652
+ * that allows you to open the CKBox dialog and edit the image.
1653
+ */
1654
+ class CKBoxImageEditUI extends Plugin {
1655
+ /**
1656
+ * @inheritDoc
1657
+ */
1658
+ static get pluginName() {
1659
+ return 'CKBoxImageEditUI';
1660
+ }
1661
+ /**
1662
+ * @inheritDoc
1663
+ */
1664
+ init() {
1665
+ const editor = this.editor;
1666
+ editor.ui.componentFactory.add('ckboxImageEdit', locale => {
1667
+ const command = editor.commands.get('ckboxImageEdit');
1668
+ const view = new ButtonView(locale);
1669
+ const t = locale.t;
1670
+ view.set({
1671
+ label: t('Edit image'),
1672
+ icon: ckboxImageEditIcon,
1673
+ tooltip: true
1674
+ });
1675
+ view.bind('isOn').to(command, 'value', command, 'isEnabled', (value, isEnabled) => value && isEnabled);
1676
+ view.bind('isEnabled').to(command);
1677
+ // Execute the command.
1678
+ this.listenTo(view, 'execute', () => {
1679
+ editor.execute('ckboxImageEdit');
1680
+ editor.editing.view.focus();
1681
+ });
1682
+ return view;
1683
+ });
1684
+ }
1685
+ }
1686
+
1687
+ /**
1688
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
1689
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
1690
+ */
1691
+ /**
1692
+ * @module ckbox/ckboximageedit
1693
+ */
1694
+ /**
1695
+ * The CKBox image edit feature.
1696
+ */
1697
+ class CKBoxImageEdit extends Plugin {
1698
+ /**
1699
+ * @inheritDoc
1700
+ */
1701
+ static get pluginName() {
1702
+ return 'CKBoxImageEdit';
1703
+ }
1704
+ /**
1705
+ * @inheritDoc
1706
+ */
1707
+ static get requires() {
1708
+ return [CKBoxImageEditEditing, CKBoxImageEditUI];
1709
+ }
1710
+ }
1711
+
1712
+ export { CKBox, CKBoxEditing, CKBoxImageEdit, CKBoxImageEditEditing, CKBoxImageEditUI, CKBoxUI };
1713
+ //# sourceMappingURL=index.js.map