@ckeditor/ckeditor5-ckbox 34.2.0

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