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

Sign up to get free protection for your applications and to get access to all the features.
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