@ckeditor/ckeditor5-ckbox 40.0.0 → 40.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,362 +1,369 @@
1
- /**
2
- * @license Copyright (c) 2003-2023, 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 } from 'ckeditor5/src/core';
6
- import { Range } from 'ckeditor5/src/engine';
7
- import { CKEditorError, logError } from 'ckeditor5/src/utils';
8
- import CKBoxCommand from './ckboxcommand';
9
- import CKBoxUploadAdapter from './ckboxuploadadapter';
10
- /**
11
- * The CKBox editing feature. It introduces the {@link module:ckbox/ckboxcommand~CKBoxCommand CKBox command} and
12
- * {@link module:ckbox/ckboxuploadadapter~CKBoxUploadAdapter CKBox upload adapter}.
13
- */
14
- export default class CKBoxEditing extends Plugin {
15
- /**
16
- * @inheritDoc
17
- */
18
- static get pluginName() {
19
- return 'CKBoxEditing';
20
- }
21
- /**
22
- * @inheritDoc
23
- */
24
- static get requires() {
25
- return ['CloudServices', 'LinkEditing', 'PictureEditing', CKBoxUploadAdapter];
26
- }
27
- /**
28
- * @inheritDoc
29
- */
30
- async init() {
31
- const editor = this.editor;
32
- const hasConfiguration = !!editor.config.get('ckbox');
33
- const isLibraryLoaded = !!window.CKBox;
34
- // Proceed with plugin initialization only when the integrator intentionally wants to use it, i.e. when the `config.ckbox` exists or
35
- // the CKBox JavaScript library is loaded.
36
- if (!hasConfiguration && !isLibraryLoaded) {
37
- return;
38
- }
39
- this._initConfig();
40
- const cloudServicesCore = editor.plugins.get('CloudServicesCore');
41
- const ckboxTokenUrl = editor.config.get('ckbox.tokenUrl');
42
- const cloudServicesTokenUrl = editor.config.get('cloudServices.tokenUrl');
43
- // To avoid fetching the same token twice we need to compare the `ckbox.tokenUrl` and `cloudServices.tokenUrl` values.
44
- // If they are equal, it's enough to take the token generated by the `CloudServices` plugin.
45
- if (ckboxTokenUrl === cloudServicesTokenUrl) {
46
- const cloudServices = editor.plugins.get('CloudServices');
47
- this._token = cloudServices.token;
48
- }
49
- // Otherwise, create a new token manually.
50
- else {
51
- this._token = await cloudServicesCore.createToken(ckboxTokenUrl).init();
52
- }
53
- // Extending the schema, registering converters and applying fixers only make sense if the configuration option to assign
54
- // the assets ID with the model elements is enabled.
55
- if (!editor.config.get('ckbox.ignoreDataId')) {
56
- this._initSchema();
57
- this._initConversion();
58
- this._initFixers();
59
- }
60
- // Registering the `ckbox` command makes sense only if the CKBox library is loaded, as the `ckbox` command opens the CKBox dialog.
61
- if (isLibraryLoaded) {
62
- editor.commands.add('ckbox', new CKBoxCommand(editor));
63
- }
64
- }
65
- /**
66
- * Returns a token used by the CKBox plugin for communication with the CKBox service.
67
- */
68
- getToken() {
69
- return this._token;
70
- }
71
- /**
72
- * Initializes the `ckbox` editor configuration.
73
- */
74
- _initConfig() {
75
- const editor = this.editor;
76
- editor.config.define('ckbox', {
77
- serviceOrigin: 'https://api.ckbox.io',
78
- defaultUploadCategories: null,
79
- ignoreDataId: false,
80
- language: editor.locale.uiLanguage,
81
- theme: 'default',
82
- tokenUrl: editor.config.get('cloudServices.tokenUrl')
83
- });
84
- const tokenUrl = editor.config.get('ckbox.tokenUrl');
85
- if (!tokenUrl) {
86
- /**
87
- * The {@link module:ckbox/ckboxconfig~CKBoxConfig#tokenUrl `config.ckbox.tokenUrl`} or the
88
- * {@link module:cloud-services/cloudservicesconfig~CloudServicesConfig#tokenUrl `config.cloudServices.tokenUrl`}
89
- * configuration is required for the CKBox plugin.
90
- *
91
- * ```ts
92
- * ClassicEditor.create( document.createElement( 'div' ), {
93
- * ckbox: {
94
- * tokenUrl: "YOUR_TOKEN_URL"
95
- * // ...
96
- * }
97
- * // ...
98
- * } );
99
- * ```
100
- *
101
- * @error ckbox-plugin-missing-token-url
102
- */
103
- throw new CKEditorError('ckbox-plugin-missing-token-url', this);
104
- }
105
- if (!editor.plugins.has('ImageBlockEditing') && !editor.plugins.has('ImageInlineEditing')) {
106
- /**
107
- * The CKBox feature requires one of the following plugins to be loaded to work correctly:
108
- *
109
- * * {@link module:image/imageblock~ImageBlock},
110
- * * {@link module:image/imageinline~ImageInline},
111
- * * {@link module:image/image~Image} (loads both `ImageBlock` and `ImageInline`)
112
- *
113
- * Please make sure your editor configuration is correct.
114
- *
115
- * @error ckbox-plugin-image-feature-missing
116
- * @param {module:core/editor/editor~Editor} editor
117
- */
118
- logError('ckbox-plugin-image-feature-missing', editor);
119
- }
120
- }
121
- /**
122
- * Extends the schema to allow the `ckboxImageId` and `ckboxLinkId` attributes for links and images.
123
- */
124
- _initSchema() {
125
- const editor = this.editor;
126
- const schema = editor.model.schema;
127
- schema.extend('$text', { allowAttributes: 'ckboxLinkId' });
128
- if (schema.isRegistered('imageBlock')) {
129
- schema.extend('imageBlock', { allowAttributes: ['ckboxImageId', 'ckboxLinkId'] });
130
- }
131
- if (schema.isRegistered('imageInline')) {
132
- schema.extend('imageInline', { allowAttributes: ['ckboxImageId', 'ckboxLinkId'] });
133
- }
134
- schema.addAttributeCheck((context, attributeName) => {
135
- const isLink = !!context.last.getAttribute('linkHref');
136
- if (!isLink && attributeName === 'ckboxLinkId') {
137
- return false;
138
- }
139
- });
140
- }
141
- /**
142
- * Configures the upcast and downcast conversions for the `ckboxImageId` and `ckboxLinkId` attributes.
143
- */
144
- _initConversion() {
145
- const editor = this.editor;
146
- // Convert `ckboxLinkId` => `data-ckbox-resource-id`.
147
- editor.conversion.for('downcast').add(dispatcher => {
148
- // Due to custom converters for linked block images, handle the `ckboxLinkId` attribute manually.
149
- dispatcher.on('attribute:ckboxLinkId:imageBlock', (evt, data, conversionApi) => {
150
- const { writer, mapper, consumable } = conversionApi;
151
- if (!consumable.consume(data.item, evt.name)) {
152
- return;
153
- }
154
- const viewFigure = mapper.toViewElement(data.item);
155
- const linkInImage = [...viewFigure.getChildren()]
156
- .find((child) => child.name === 'a');
157
- // No link inside an image - no conversion needed.
158
- if (!linkInImage) {
159
- return;
160
- }
161
- if (data.item.hasAttribute('ckboxLinkId')) {
162
- writer.setAttribute('data-ckbox-resource-id', data.item.getAttribute('ckboxLinkId'), linkInImage);
163
- }
164
- else {
165
- writer.removeAttribute('data-ckbox-resource-id', linkInImage);
166
- }
167
- }, { priority: 'low' });
168
- dispatcher.on('attribute:ckboxLinkId', (evt, data, conversionApi) => {
169
- const { writer, mapper, consumable } = conversionApi;
170
- if (!consumable.consume(data.item, evt.name)) {
171
- return;
172
- }
173
- // Remove the previous attribute value if it was applied.
174
- if (data.attributeOldValue) {
175
- const viewElement = createLinkElement(writer, data.attributeOldValue);
176
- writer.unwrap(mapper.toViewRange(data.range), viewElement);
177
- }
178
- // Add the new attribute value if specified in a model element.
179
- if (data.attributeNewValue) {
180
- const viewElement = createLinkElement(writer, data.attributeNewValue);
181
- if (data.item.is('selection')) {
182
- const viewSelection = writer.document.selection;
183
- writer.wrap(viewSelection.getFirstRange(), viewElement);
184
- }
185
- else {
186
- writer.wrap(mapper.toViewRange(data.range), viewElement);
187
- }
188
- }
189
- }, { priority: 'low' });
190
- });
191
- // Convert `data-ckbox-resource-id` => `ckboxLinkId`.
192
- //
193
- // The helper conversion does not handle all cases, so take care of the `data-ckbox-resource-id` attribute manually for images
194
- // and links.
195
- editor.conversion.for('upcast').add(dispatcher => {
196
- dispatcher.on('element:a', (evt, data, conversionApi) => {
197
- const { writer, consumable } = conversionApi;
198
- // Upcast the `data-ckbox-resource-id` attribute only for valid link elements.
199
- if (!data.viewItem.getAttribute('href')) {
200
- return;
201
- }
202
- const consumableAttributes = { attributes: ['data-ckbox-resource-id'] };
203
- if (!consumable.consume(data.viewItem, consumableAttributes)) {
204
- return;
205
- }
206
- const attributeValue = data.viewItem.getAttribute('data-ckbox-resource-id');
207
- // Missing the `data-ckbox-resource-id` attribute.
208
- if (!attributeValue) {
209
- return;
210
- }
211
- if (data.modelRange) {
212
- // If the `<a>` element contains more than single children (e.g. a linked image), set the `ckboxLinkId` for each
213
- // allowed child.
214
- for (let item of data.modelRange.getItems()) {
215
- if (item.is('$textProxy')) {
216
- item = item.textNode;
217
- }
218
- // Do not copy the `ckboxLinkId` attribute when wrapping an element in a block element, e.g. when
219
- // auto-paragraphing.
220
- if (shouldUpcastAttributeForNode(item)) {
221
- writer.setAttribute('ckboxLinkId', attributeValue, item);
222
- }
223
- }
224
- }
225
- else {
226
- // Otherwise, just set the `ckboxLinkId` for the model element.
227
- const modelElement = data.modelCursor.nodeBefore || data.modelCursor.parent;
228
- writer.setAttribute('ckboxLinkId', attributeValue, modelElement);
229
- }
230
- }, { priority: 'low' });
231
- });
232
- // Convert `ckboxImageId` => `data-ckbox-resource-id`.
233
- editor.conversion.for('downcast').attributeToAttribute({
234
- model: 'ckboxImageId',
235
- view: 'data-ckbox-resource-id'
236
- });
237
- // Convert `data-ckbox-resource-id` => `ckboxImageId`.
238
- editor.conversion.for('upcast').elementToAttribute({
239
- model: {
240
- key: 'ckboxImageId',
241
- value: (viewElement) => viewElement.getAttribute('data-ckbox-resource-id')
242
- },
243
- view: {
244
- attributes: {
245
- 'data-ckbox-resource-id': /[\s\S]+/
246
- }
247
- }
248
- });
249
- }
250
- /**
251
- * Registers post-fixers that add or remove the `ckboxLinkId` and `ckboxImageId` attributes.
252
- */
253
- _initFixers() {
254
- const editor = this.editor;
255
- const model = editor.model;
256
- const selection = model.document.selection;
257
- // Registers the post-fixer to sync the asset ID with the model elements.
258
- model.document.registerPostFixer(syncDataIdPostFixer(editor));
259
- // Registers the post-fixer to remove the `ckboxLinkId` attribute from the model selection.
260
- model.document.registerPostFixer(injectSelectionPostFixer(selection));
261
- }
262
- }
263
- /**
264
- * A post-fixer that synchronizes the asset ID with the model element.
265
- */
266
- function syncDataIdPostFixer(editor) {
267
- return (writer) => {
268
- let changed = false;
269
- const model = editor.model;
270
- const ckboxCommand = editor.commands.get('ckbox');
271
- // The ID from chosen assets are stored in the `CKBoxCommand#_chosenAssets`. If there is no command, it makes no sense to check
272
- // for changes in the model.
273
- if (!ckboxCommand) {
274
- return changed;
275
- }
276
- for (const entry of model.document.differ.getChanges()) {
277
- if (entry.type !== 'insert' && entry.type !== 'attribute') {
278
- continue;
279
- }
280
- const range = entry.type === 'insert' ?
281
- new Range(entry.position, entry.position.getShiftedBy(entry.length)) :
282
- entry.range;
283
- const isLinkHrefAttributeRemoval = entry.type === 'attribute' &&
284
- entry.attributeKey === 'linkHref' &&
285
- entry.attributeNewValue === null;
286
- for (const item of range.getItems()) {
287
- // If the `linkHref` attribute has been removed, sync the change with the `ckboxLinkId` attribute.
288
- if (isLinkHrefAttributeRemoval && item.hasAttribute('ckboxLinkId')) {
289
- writer.removeAttribute('ckboxLinkId', item);
290
- changed = true;
291
- continue;
292
- }
293
- // Otherwise, the change concerns either a new model element or an attribute change. Try to find the assets for the modified
294
- // model element.
295
- const assets = findAssetsForItem(item, ckboxCommand._chosenAssets);
296
- for (const asset of assets) {
297
- const attributeName = asset.type === 'image' ? 'ckboxImageId' : 'ckboxLinkId';
298
- if (asset.id === item.getAttribute(attributeName)) {
299
- continue;
300
- }
301
- writer.setAttribute(attributeName, asset.id, item);
302
- changed = true;
303
- }
304
- }
305
- }
306
- return changed;
307
- };
308
- }
309
- /**
310
- * A post-fixer that removes the `ckboxLinkId` from the selection if it does not represent a link anymore.
311
- */
312
- function injectSelectionPostFixer(selection) {
313
- return (writer) => {
314
- const shouldRemoveLinkIdAttribute = !selection.hasAttribute('linkHref') && selection.hasAttribute('ckboxLinkId');
315
- if (shouldRemoveLinkIdAttribute) {
316
- writer.removeSelectionAttribute('ckboxLinkId');
317
- return true;
318
- }
319
- return false;
320
- };
321
- }
322
- /**
323
- * Tries to find the asset that is associated with the model element by comparing the attributes:
324
- * - the image fallback URL with the `src` attribute for images,
325
- * - the link URL with the `href` attribute for links.
326
- *
327
- * 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
328
- * image asset).
329
- */
330
- function findAssetsForItem(item, assets) {
331
- const isImageElement = item.is('element', 'imageInline') || item.is('element', 'imageBlock');
332
- const isLinkElement = item.hasAttribute('linkHref');
333
- return [...assets].filter(asset => {
334
- if (asset.type === 'image' && isImageElement) {
335
- return asset.attributes.imageFallbackUrl === item.getAttribute('src');
336
- }
337
- if (asset.type === 'link' && isLinkElement) {
338
- return asset.attributes.linkHref === item.getAttribute('linkHref');
339
- }
340
- });
341
- }
342
- /**
343
- * Creates view link element with the requested ID.
344
- */
345
- function createLinkElement(writer, id) {
346
- // Priority equal 5 is needed to merge adjacent `<a>` elements together.
347
- const viewElement = writer.createAttributeElement('a', { 'data-ckbox-resource-id': id }, { priority: 5 });
348
- writer.setCustomProperty('link', true, viewElement);
349
- return viewElement;
350
- }
351
- /**
352
- * Checks if the model element may have the `ckboxLinkId` attribute.
353
- */
354
- function shouldUpcastAttributeForNode(node) {
355
- if (node.is('$text')) {
356
- return true;
357
- }
358
- if (node.is('element', 'imageInline') || node.is('element', 'imageBlock')) {
359
- return true;
360
- }
361
- return false;
362
- }
1
+ /**
2
+ * @license Copyright (c) 2003-2023, 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 } from 'ckeditor5/src/core';
6
+ import { Range } from 'ckeditor5/src/engine';
7
+ import { CKEditorError, logError } from 'ckeditor5/src/utils';
8
+ import CKBoxCommand from './ckboxcommand';
9
+ import CKBoxUploadAdapter from './ckboxuploadadapter';
10
+ const DEFAULT_CKBOX_THEME_NAME = 'lark';
11
+ /**
12
+ * The CKBox editing feature. It introduces the {@link module:ckbox/ckboxcommand~CKBoxCommand CKBox command} and
13
+ * {@link module:ckbox/ckboxuploadadapter~CKBoxUploadAdapter CKBox upload adapter}.
14
+ */
15
+ export default class CKBoxEditing extends Plugin {
16
+ /**
17
+ * @inheritDoc
18
+ */
19
+ static get pluginName() {
20
+ return 'CKBoxEditing';
21
+ }
22
+ /**
23
+ * @inheritDoc
24
+ */
25
+ static get requires() {
26
+ return ['CloudServices', 'LinkEditing', 'PictureEditing', CKBoxUploadAdapter];
27
+ }
28
+ /**
29
+ * @inheritDoc
30
+ */
31
+ async init() {
32
+ const editor = this.editor;
33
+ const hasConfiguration = !!editor.config.get('ckbox');
34
+ const isLibraryLoaded = !!window.CKBox;
35
+ // Proceed with plugin initialization only when the integrator intentionally wants to use it, i.e. when the `config.ckbox` exists or
36
+ // the CKBox JavaScript library is loaded.
37
+ if (!hasConfiguration && !isLibraryLoaded) {
38
+ return;
39
+ }
40
+ this._initConfig();
41
+ const cloudServicesCore = editor.plugins.get('CloudServicesCore');
42
+ const ckboxTokenUrl = editor.config.get('ckbox.tokenUrl');
43
+ const cloudServicesTokenUrl = editor.config.get('cloudServices.tokenUrl');
44
+ // To avoid fetching the same token twice we need to compare the `ckbox.tokenUrl` and `cloudServices.tokenUrl` values.
45
+ // If they are equal, it's enough to take the token generated by the `CloudServices` plugin.
46
+ if (ckboxTokenUrl === cloudServicesTokenUrl) {
47
+ const cloudServices = editor.plugins.get('CloudServices');
48
+ this._token = cloudServices.token;
49
+ }
50
+ // Otherwise, create a new token manually.
51
+ else {
52
+ this._token = await cloudServicesCore.createToken(ckboxTokenUrl).init();
53
+ }
54
+ // Extending the schema, registering converters and applying fixers only make sense if the configuration option to assign
55
+ // the assets ID with the model elements is enabled.
56
+ if (!editor.config.get('ckbox.ignoreDataId')) {
57
+ this._initSchema();
58
+ this._initConversion();
59
+ this._initFixers();
60
+ }
61
+ // Registering the `ckbox` command makes sense only if the CKBox library is loaded, as the `ckbox` command opens the CKBox dialog.
62
+ if (isLibraryLoaded) {
63
+ editor.commands.add('ckbox', new CKBoxCommand(editor));
64
+ }
65
+ }
66
+ /**
67
+ * Returns a token used by the CKBox plugin for communication with the CKBox service.
68
+ */
69
+ getToken() {
70
+ return this._token;
71
+ }
72
+ /**
73
+ * Initializes the `ckbox` editor configuration.
74
+ */
75
+ _initConfig() {
76
+ const editor = this.editor;
77
+ editor.config.define('ckbox', {
78
+ serviceOrigin: 'https://api.ckbox.io',
79
+ defaultUploadCategories: null,
80
+ ignoreDataId: false,
81
+ language: editor.locale.uiLanguage,
82
+ theme: DEFAULT_CKBOX_THEME_NAME,
83
+ tokenUrl: editor.config.get('cloudServices.tokenUrl')
84
+ });
85
+ const tokenUrl = editor.config.get('ckbox.tokenUrl');
86
+ if (!tokenUrl) {
87
+ /**
88
+ * The {@link module:ckbox/ckboxconfig~CKBoxConfig#tokenUrl `config.ckbox.tokenUrl`} or the
89
+ * {@link module:cloud-services/cloudservicesconfig~CloudServicesConfig#tokenUrl `config.cloudServices.tokenUrl`}
90
+ * configuration is required for the CKBox plugin.
91
+ *
92
+ * ```ts
93
+ * ClassicEditor.create( document.createElement( 'div' ), {
94
+ * ckbox: {
95
+ * tokenUrl: "YOUR_TOKEN_URL"
96
+ * // ...
97
+ * }
98
+ * // ...
99
+ * } );
100
+ * ```
101
+ *
102
+ * @error ckbox-plugin-missing-token-url
103
+ */
104
+ throw new CKEditorError('ckbox-plugin-missing-token-url', this);
105
+ }
106
+ if (!editor.plugins.has('ImageBlockEditing') && !editor.plugins.has('ImageInlineEditing')) {
107
+ /**
108
+ * The CKBox feature requires one of the following plugins to be loaded to work correctly:
109
+ *
110
+ * * {@link module:image/imageblock~ImageBlock},
111
+ * * {@link module:image/imageinline~ImageInline},
112
+ * * {@link module:image/image~Image} (loads both `ImageBlock` and `ImageInline`)
113
+ *
114
+ * Please make sure your editor configuration is correct.
115
+ *
116
+ * @error ckbox-plugin-image-feature-missing
117
+ * @param {module:core/editor/editor~Editor} editor
118
+ */
119
+ logError('ckbox-plugin-image-feature-missing', editor);
120
+ }
121
+ }
122
+ /**
123
+ * Extends the schema to allow the `ckboxImageId` and `ckboxLinkId` attributes for links and images.
124
+ */
125
+ _initSchema() {
126
+ const editor = this.editor;
127
+ const schema = editor.model.schema;
128
+ schema.extend('$text', { allowAttributes: 'ckboxLinkId' });
129
+ if (schema.isRegistered('imageBlock')) {
130
+ schema.extend('imageBlock', { allowAttributes: ['ckboxImageId', 'ckboxLinkId'] });
131
+ }
132
+ if (schema.isRegistered('imageInline')) {
133
+ schema.extend('imageInline', { allowAttributes: ['ckboxImageId', 'ckboxLinkId'] });
134
+ }
135
+ schema.addAttributeCheck((context, attributeName) => {
136
+ const isLink = !!context.last.getAttribute('linkHref');
137
+ if (!isLink && attributeName === 'ckboxLinkId') {
138
+ return false;
139
+ }
140
+ });
141
+ }
142
+ /**
143
+ * Configures the upcast and downcast conversions for the `ckboxImageId` and `ckboxLinkId` attributes.
144
+ */
145
+ _initConversion() {
146
+ const editor = this.editor;
147
+ // Convert `ckboxLinkId` => `data-ckbox-resource-id`.
148
+ editor.conversion.for('downcast').add(dispatcher => {
149
+ // Due to custom converters for linked block images, handle the `ckboxLinkId` attribute manually.
150
+ dispatcher.on('attribute:ckboxLinkId:imageBlock', (evt, data, conversionApi) => {
151
+ const { writer, mapper, consumable } = conversionApi;
152
+ if (!consumable.consume(data.item, evt.name)) {
153
+ return;
154
+ }
155
+ const viewFigure = mapper.toViewElement(data.item);
156
+ const linkInImage = [...viewFigure.getChildren()]
157
+ .find((child) => child.name === 'a');
158
+ // No link inside an image - no conversion needed.
159
+ if (!linkInImage) {
160
+ return;
161
+ }
162
+ if (data.item.hasAttribute('ckboxLinkId')) {
163
+ writer.setAttribute('data-ckbox-resource-id', data.item.getAttribute('ckboxLinkId'), linkInImage);
164
+ }
165
+ else {
166
+ writer.removeAttribute('data-ckbox-resource-id', linkInImage);
167
+ }
168
+ }, { priority: 'low' });
169
+ dispatcher.on('attribute:ckboxLinkId', (evt, data, conversionApi) => {
170
+ const { writer, mapper, consumable } = conversionApi;
171
+ if (!consumable.consume(data.item, evt.name)) {
172
+ return;
173
+ }
174
+ // Remove the previous attribute value if it was applied.
175
+ if (data.attributeOldValue) {
176
+ const viewElement = createLinkElement(writer, data.attributeOldValue);
177
+ writer.unwrap(mapper.toViewRange(data.range), viewElement);
178
+ }
179
+ // Add the new attribute value if specified in a model element.
180
+ if (data.attributeNewValue) {
181
+ const viewElement = createLinkElement(writer, data.attributeNewValue);
182
+ if (data.item.is('selection')) {
183
+ const viewSelection = writer.document.selection;
184
+ writer.wrap(viewSelection.getFirstRange(), viewElement);
185
+ }
186
+ else {
187
+ writer.wrap(mapper.toViewRange(data.range), viewElement);
188
+ }
189
+ }
190
+ }, { priority: 'low' });
191
+ });
192
+ // Convert `data-ckbox-resource-id` => `ckboxLinkId`.
193
+ //
194
+ // The helper conversion does not handle all cases, so take care of the `data-ckbox-resource-id` attribute manually for images
195
+ // and links.
196
+ editor.conversion.for('upcast').add(dispatcher => {
197
+ dispatcher.on('element:a', (evt, data, conversionApi) => {
198
+ const { writer, consumable } = conversionApi;
199
+ // Upcast the `data-ckbox-resource-id` attribute only for valid link elements.
200
+ if (!data.viewItem.getAttribute('href')) {
201
+ return;
202
+ }
203
+ const consumableAttributes = { attributes: ['data-ckbox-resource-id'] };
204
+ if (!consumable.consume(data.viewItem, consumableAttributes)) {
205
+ return;
206
+ }
207
+ const attributeValue = data.viewItem.getAttribute('data-ckbox-resource-id');
208
+ // Missing the `data-ckbox-resource-id` attribute.
209
+ if (!attributeValue) {
210
+ return;
211
+ }
212
+ if (data.modelRange) {
213
+ // If the `<a>` element contains more than single children (e.g. a linked image), set the `ckboxLinkId` for each
214
+ // allowed child.
215
+ for (let item of data.modelRange.getItems()) {
216
+ if (item.is('$textProxy')) {
217
+ item = item.textNode;
218
+ }
219
+ // Do not copy the `ckboxLinkId` attribute when wrapping an element in a block element, e.g. when
220
+ // auto-paragraphing.
221
+ if (shouldUpcastAttributeForNode(item)) {
222
+ writer.setAttribute('ckboxLinkId', attributeValue, item);
223
+ }
224
+ }
225
+ }
226
+ else {
227
+ // Otherwise, just set the `ckboxLinkId` for the model element.
228
+ const modelElement = data.modelCursor.nodeBefore || data.modelCursor.parent;
229
+ writer.setAttribute('ckboxLinkId', attributeValue, modelElement);
230
+ }
231
+ }, { priority: 'low' });
232
+ });
233
+ // Convert `ckboxImageId` => `data-ckbox-resource-id`.
234
+ editor.conversion.for('downcast').attributeToAttribute({
235
+ model: 'ckboxImageId',
236
+ view: 'data-ckbox-resource-id'
237
+ });
238
+ // Convert `data-ckbox-resource-id` => `ckboxImageId`.
239
+ editor.conversion.for('upcast').elementToAttribute({
240
+ model: {
241
+ key: 'ckboxImageId',
242
+ value: (viewElement) => viewElement.getAttribute('data-ckbox-resource-id')
243
+ },
244
+ view: {
245
+ attributes: {
246
+ 'data-ckbox-resource-id': /[\s\S]+/
247
+ }
248
+ }
249
+ });
250
+ const replaceImageSourceCommand = editor.commands.get('replaceImageSource');
251
+ if (replaceImageSourceCommand) {
252
+ this.listenTo(replaceImageSourceCommand, 'cleanupImage', (_, [writer, image]) => {
253
+ writer.removeAttribute('ckboxImageId', image);
254
+ });
255
+ }
256
+ }
257
+ /**
258
+ * Registers post-fixers that add or remove the `ckboxLinkId` and `ckboxImageId` attributes.
259
+ */
260
+ _initFixers() {
261
+ const editor = this.editor;
262
+ const model = editor.model;
263
+ const selection = model.document.selection;
264
+ // Registers the post-fixer to sync the asset ID with the model elements.
265
+ model.document.registerPostFixer(syncDataIdPostFixer(editor));
266
+ // Registers the post-fixer to remove the `ckboxLinkId` attribute from the model selection.
267
+ model.document.registerPostFixer(injectSelectionPostFixer(selection));
268
+ }
269
+ }
270
+ /**
271
+ * A post-fixer that synchronizes the asset ID with the model element.
272
+ */
273
+ function syncDataIdPostFixer(editor) {
274
+ return (writer) => {
275
+ let changed = false;
276
+ const model = editor.model;
277
+ const ckboxCommand = editor.commands.get('ckbox');
278
+ // The ID from chosen assets are stored in the `CKBoxCommand#_chosenAssets`. If there is no command, it makes no sense to check
279
+ // for changes in the model.
280
+ if (!ckboxCommand) {
281
+ return changed;
282
+ }
283
+ for (const entry of model.document.differ.getChanges()) {
284
+ if (entry.type !== 'insert' && entry.type !== 'attribute') {
285
+ continue;
286
+ }
287
+ const range = entry.type === 'insert' ?
288
+ new Range(entry.position, entry.position.getShiftedBy(entry.length)) :
289
+ entry.range;
290
+ const isLinkHrefAttributeRemoval = entry.type === 'attribute' &&
291
+ entry.attributeKey === 'linkHref' &&
292
+ entry.attributeNewValue === null;
293
+ for (const item of range.getItems()) {
294
+ // If the `linkHref` attribute has been removed, sync the change with the `ckboxLinkId` attribute.
295
+ if (isLinkHrefAttributeRemoval && item.hasAttribute('ckboxLinkId')) {
296
+ writer.removeAttribute('ckboxLinkId', item);
297
+ changed = true;
298
+ continue;
299
+ }
300
+ // Otherwise, the change concerns either a new model element or an attribute change. Try to find the assets for the modified
301
+ // model element.
302
+ const assets = findAssetsForItem(item, ckboxCommand._chosenAssets);
303
+ for (const asset of assets) {
304
+ const attributeName = asset.type === 'image' ? 'ckboxImageId' : 'ckboxLinkId';
305
+ if (asset.id === item.getAttribute(attributeName)) {
306
+ continue;
307
+ }
308
+ writer.setAttribute(attributeName, asset.id, item);
309
+ changed = true;
310
+ }
311
+ }
312
+ }
313
+ return changed;
314
+ };
315
+ }
316
+ /**
317
+ * A post-fixer that removes the `ckboxLinkId` from the selection if it does not represent a link anymore.
318
+ */
319
+ function injectSelectionPostFixer(selection) {
320
+ return (writer) => {
321
+ const shouldRemoveLinkIdAttribute = !selection.hasAttribute('linkHref') && selection.hasAttribute('ckboxLinkId');
322
+ if (shouldRemoveLinkIdAttribute) {
323
+ writer.removeSelectionAttribute('ckboxLinkId');
324
+ return true;
325
+ }
326
+ return false;
327
+ };
328
+ }
329
+ /**
330
+ * Tries to find the asset that is associated with the model element by comparing the attributes:
331
+ * - the image fallback URL with the `src` attribute for images,
332
+ * - the link URL with the `href` attribute for links.
333
+ *
334
+ * 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
335
+ * image asset).
336
+ */
337
+ function findAssetsForItem(item, assets) {
338
+ const isImageElement = item.is('element', 'imageInline') || item.is('element', 'imageBlock');
339
+ const isLinkElement = item.hasAttribute('linkHref');
340
+ return [...assets].filter(asset => {
341
+ if (asset.type === 'image' && isImageElement) {
342
+ return asset.attributes.imageFallbackUrl === item.getAttribute('src');
343
+ }
344
+ if (asset.type === 'link' && isLinkElement) {
345
+ return asset.attributes.linkHref === item.getAttribute('linkHref');
346
+ }
347
+ });
348
+ }
349
+ /**
350
+ * Creates view link element with the requested ID.
351
+ */
352
+ function createLinkElement(writer, id) {
353
+ // Priority equal 5 is needed to merge adjacent `<a>` elements together.
354
+ const viewElement = writer.createAttributeElement('a', { 'data-ckbox-resource-id': id }, { priority: 5 });
355
+ writer.setCustomProperty('link', true, viewElement);
356
+ return viewElement;
357
+ }
358
+ /**
359
+ * Checks if the model element may have the `ckboxLinkId` attribute.
360
+ */
361
+ function shouldUpcastAttributeForNode(node) {
362
+ if (node.is('$text')) {
363
+ return true;
364
+ }
365
+ if (node.is('element', 'imageInline') || node.is('element', 'imageBlock')) {
366
+ return true;
367
+ }
368
+ return false;
369
+ }