@ckeditor/ckeditor5-ckbox 39.0.2 → 40.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,362 +1,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
- /**
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
+ /**
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
+ }