@ckeditor/ckeditor5-ckbox 38.1.1 → 38.2.0-alpha.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,363 +1,363 @@
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
- assetsOrigin: 'https://ckbox.cloud',
79
- defaultUploadCategories: null,
80
- ignoreDataId: false,
81
- language: editor.locale.uiLanguage,
82
- theme: 'default',
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
- }
251
- /**
252
- * Registers post-fixers that add or remove the `ckboxLinkId` and `ckboxImageId` attributes.
253
- */
254
- _initFixers() {
255
- const editor = this.editor;
256
- const model = editor.model;
257
- const selection = model.document.selection;
258
- // Registers the post-fixer to sync the asset ID with the model elements.
259
- model.document.registerPostFixer(syncDataIdPostFixer(editor));
260
- // Registers the post-fixer to remove the `ckboxLinkId` attribute from the model selection.
261
- model.document.registerPostFixer(injectSelectionPostFixer(selection));
262
- }
263
- }
264
- /**
265
- * A post-fixer that synchronizes the asset ID with the model element.
266
- */
267
- function syncDataIdPostFixer(editor) {
268
- return (writer) => {
269
- let changed = false;
270
- const model = editor.model;
271
- const ckboxCommand = editor.commands.get('ckbox');
272
- // The ID from chosen assets are stored in the `CKBoxCommand#_chosenAssets`. If there is no command, it makes no sense to check
273
- // for changes in the model.
274
- if (!ckboxCommand) {
275
- return changed;
276
- }
277
- for (const entry of model.document.differ.getChanges()) {
278
- if (entry.type !== 'insert' && entry.type !== 'attribute') {
279
- continue;
280
- }
281
- const range = entry.type === 'insert' ?
282
- new Range(entry.position, entry.position.getShiftedBy(entry.length)) :
283
- entry.range;
284
- const isLinkHrefAttributeRemoval = entry.type === 'attribute' &&
285
- entry.attributeKey === 'linkHref' &&
286
- entry.attributeNewValue === null;
287
- for (const item of range.getItems()) {
288
- // If the `linkHref` attribute has been removed, sync the change with the `ckboxLinkId` attribute.
289
- if (isLinkHrefAttributeRemoval && item.hasAttribute('ckboxLinkId')) {
290
- writer.removeAttribute('ckboxLinkId', item);
291
- changed = true;
292
- continue;
293
- }
294
- // Otherwise, the change concerns either a new model element or an attribute change. Try to find the assets for the modified
295
- // model element.
296
- const assets = findAssetsForItem(item, ckboxCommand._chosenAssets);
297
- for (const asset of assets) {
298
- const attributeName = asset.type === 'image' ? 'ckboxImageId' : 'ckboxLinkId';
299
- if (asset.id === item.getAttribute(attributeName)) {
300
- continue;
301
- }
302
- writer.setAttribute(attributeName, asset.id, item);
303
- changed = true;
304
- }
305
- }
306
- }
307
- return changed;
308
- };
309
- }
310
- /**
311
- * A post-fixer that removes the `ckboxLinkId` from the selection if it does not represent a link anymore.
312
- */
313
- function injectSelectionPostFixer(selection) {
314
- return (writer) => {
315
- const shouldRemoveLinkIdAttribute = !selection.hasAttribute('linkHref') && selection.hasAttribute('ckboxLinkId');
316
- if (shouldRemoveLinkIdAttribute) {
317
- writer.removeSelectionAttribute('ckboxLinkId');
318
- return true;
319
- }
320
- return false;
321
- };
322
- }
323
- /**
324
- * Tries to find the asset that is associated with the model element by comparing the attributes:
325
- * - the image fallback URL with the `src` attribute for images,
326
- * - the link URL with the `href` attribute for links.
327
- *
328
- * 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
329
- * image asset).
330
- */
331
- function findAssetsForItem(item, assets) {
332
- const isImageElement = item.is('element', 'imageInline') || item.is('element', 'imageBlock');
333
- const isLinkElement = item.hasAttribute('linkHref');
334
- return [...assets].filter(asset => {
335
- if (asset.type === 'image' && isImageElement) {
336
- return asset.attributes.imageFallbackUrl === item.getAttribute('src');
337
- }
338
- if (asset.type === 'link' && isLinkElement) {
339
- return asset.attributes.linkHref === item.getAttribute('linkHref');
340
- }
341
- });
342
- }
343
- /**
344
- * Creates view link element with the requested ID.
345
- */
346
- function createLinkElement(writer, id) {
347
- // Priority equal 5 is needed to merge adjacent `<a>` elements together.
348
- const viewElement = writer.createAttributeElement('a', { 'data-ckbox-resource-id': id }, { priority: 5 });
349
- writer.setCustomProperty('link', true, viewElement);
350
- return viewElement;
351
- }
352
- /**
353
- * Checks if the model element may have the `ckboxLinkId` attribute.
354
- */
355
- function shouldUpcastAttributeForNode(node) {
356
- if (node.is('$text')) {
357
- return true;
358
- }
359
- if (node.is('element', 'imageInline') || node.is('element', 'imageBlock')) {
360
- return true;
361
- }
362
- return false;
363
- }
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.js';
6
+ import { Range } from 'ckeditor5/src/engine.js';
7
+ import { CKEditorError, logError } from 'ckeditor5/src/utils.js';
8
+ import CKBoxCommand from './ckboxcommand.js';
9
+ import CKBoxUploadAdapter from './ckboxuploadadapter.js';
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
+ assetsOrigin: 'https://ckbox.cloud',
79
+ defaultUploadCategories: null,
80
+ ignoreDataId: false,
81
+ language: editor.locale.uiLanguage,
82
+ theme: 'default',
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
+ }
251
+ /**
252
+ * Registers post-fixers that add or remove the `ckboxLinkId` and `ckboxImageId` attributes.
253
+ */
254
+ _initFixers() {
255
+ const editor = this.editor;
256
+ const model = editor.model;
257
+ const selection = model.document.selection;
258
+ // Registers the post-fixer to sync the asset ID with the model elements.
259
+ model.document.registerPostFixer(syncDataIdPostFixer(editor));
260
+ // Registers the post-fixer to remove the `ckboxLinkId` attribute from the model selection.
261
+ model.document.registerPostFixer(injectSelectionPostFixer(selection));
262
+ }
263
+ }
264
+ /**
265
+ * A post-fixer that synchronizes the asset ID with the model element.
266
+ */
267
+ function syncDataIdPostFixer(editor) {
268
+ return (writer) => {
269
+ let changed = false;
270
+ const model = editor.model;
271
+ const ckboxCommand = editor.commands.get('ckbox');
272
+ // The ID from chosen assets are stored in the `CKBoxCommand#_chosenAssets`. If there is no command, it makes no sense to check
273
+ // for changes in the model.
274
+ if (!ckboxCommand) {
275
+ return changed;
276
+ }
277
+ for (const entry of model.document.differ.getChanges()) {
278
+ if (entry.type !== 'insert' && entry.type !== 'attribute') {
279
+ continue;
280
+ }
281
+ const range = entry.type === 'insert' ?
282
+ new Range(entry.position, entry.position.getShiftedBy(entry.length)) :
283
+ entry.range;
284
+ const isLinkHrefAttributeRemoval = entry.type === 'attribute' &&
285
+ entry.attributeKey === 'linkHref' &&
286
+ entry.attributeNewValue === null;
287
+ for (const item of range.getItems()) {
288
+ // If the `linkHref` attribute has been removed, sync the change with the `ckboxLinkId` attribute.
289
+ if (isLinkHrefAttributeRemoval && item.hasAttribute('ckboxLinkId')) {
290
+ writer.removeAttribute('ckboxLinkId', item);
291
+ changed = true;
292
+ continue;
293
+ }
294
+ // Otherwise, the change concerns either a new model element or an attribute change. Try to find the assets for the modified
295
+ // model element.
296
+ const assets = findAssetsForItem(item, ckboxCommand._chosenAssets);
297
+ for (const asset of assets) {
298
+ const attributeName = asset.type === 'image' ? 'ckboxImageId' : 'ckboxLinkId';
299
+ if (asset.id === item.getAttribute(attributeName)) {
300
+ continue;
301
+ }
302
+ writer.setAttribute(attributeName, asset.id, item);
303
+ changed = true;
304
+ }
305
+ }
306
+ }
307
+ return changed;
308
+ };
309
+ }
310
+ /**
311
+ * A post-fixer that removes the `ckboxLinkId` from the selection if it does not represent a link anymore.
312
+ */
313
+ function injectSelectionPostFixer(selection) {
314
+ return (writer) => {
315
+ const shouldRemoveLinkIdAttribute = !selection.hasAttribute('linkHref') && selection.hasAttribute('ckboxLinkId');
316
+ if (shouldRemoveLinkIdAttribute) {
317
+ writer.removeSelectionAttribute('ckboxLinkId');
318
+ return true;
319
+ }
320
+ return false;
321
+ };
322
+ }
323
+ /**
324
+ * Tries to find the asset that is associated with the model element by comparing the attributes:
325
+ * - the image fallback URL with the `src` attribute for images,
326
+ * - the link URL with the `href` attribute for links.
327
+ *
328
+ * 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
329
+ * image asset).
330
+ */
331
+ function findAssetsForItem(item, assets) {
332
+ const isImageElement = item.is('element', 'imageInline') || item.is('element', 'imageBlock');
333
+ const isLinkElement = item.hasAttribute('linkHref');
334
+ return [...assets].filter(asset => {
335
+ if (asset.type === 'image' && isImageElement) {
336
+ return asset.attributes.imageFallbackUrl === item.getAttribute('src');
337
+ }
338
+ if (asset.type === 'link' && isLinkElement) {
339
+ return asset.attributes.linkHref === item.getAttribute('linkHref');
340
+ }
341
+ });
342
+ }
343
+ /**
344
+ * Creates view link element with the requested ID.
345
+ */
346
+ function createLinkElement(writer, id) {
347
+ // Priority equal 5 is needed to merge adjacent `<a>` elements together.
348
+ const viewElement = writer.createAttributeElement('a', { 'data-ckbox-resource-id': id }, { priority: 5 });
349
+ writer.setCustomProperty('link', true, viewElement);
350
+ return viewElement;
351
+ }
352
+ /**
353
+ * Checks if the model element may have the `ckboxLinkId` attribute.
354
+ */
355
+ function shouldUpcastAttributeForNode(node) {
356
+ if (node.is('$text')) {
357
+ return true;
358
+ }
359
+ if (node.is('element', 'imageInline') || node.is('element', 'imageBlock')) {
360
+ return true;
361
+ }
362
+ return false;
363
+ }