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