@ckeditor/ckeditor5-html-support 32.0.0 → 34.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,12 +10,30 @@
10
10
  import { cloneDeep } from 'lodash-es';
11
11
 
12
12
  /**
13
- * Helper function for downcast converter. Sets attributes on the given view element.
13
+ * Helper function for the downcast converter. Updates attributes on the given view element.
14
14
  *
15
- * @param {module:engine/view/downcastwriter~DowncastWriter} writer
16
- * @param {Object} viewAttributes
17
- * @param {module:engine/view/element~Element} viewElement
15
+ * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer.
16
+ * @param {Object} oldViewAttributes The previous GHS attribute value.
17
+ * @param {Object} newViewAttributes The current GHS attribute value.
18
+ * @param {module:engine/view/element~Element} viewElement The view element to update.
18
19
  */
20
+ export function updateViewAttributes( writer, oldViewAttributes, newViewAttributes, viewElement ) {
21
+ if ( oldViewAttributes ) {
22
+ removeViewAttributes( writer, oldViewAttributes, viewElement );
23
+ }
24
+
25
+ if ( newViewAttributes ) {
26
+ setViewAttributes( writer, newViewAttributes, viewElement );
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Helper function for the downcast converter. Sets attributes on the given view element.
32
+ *
33
+ * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer.
34
+ * @param {Object} viewAttributes The GHS attribute value.
35
+ * @param {module:engine/view/element~Element} viewElement The view element to update.
36
+ */
19
37
  export function setViewAttributes( writer, viewAttributes, viewElement ) {
20
38
  if ( viewAttributes.attributes ) {
21
39
  for ( const [ key, value ] of Object.entries( viewAttributes.attributes ) ) {
@@ -32,6 +50,31 @@ export function setViewAttributes( writer, viewAttributes, viewElement ) {
32
50
  }
33
51
  }
34
52
 
53
+ /**
54
+ * Helper function for the downcast converter. Removes attributes on the given view element.
55
+ *
56
+ * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer.
57
+ * @param {Object} viewAttributes The GHS attribute value.
58
+ * @param {module:engine/view/element~Element} viewElement The view element to update.
59
+ */
60
+ export function removeViewAttributes( writer, viewAttributes, viewElement ) {
61
+ if ( viewAttributes.attributes ) {
62
+ for ( const [ key ] of Object.entries( viewAttributes.attributes ) ) {
63
+ writer.removeAttribute( key, viewElement );
64
+ }
65
+ }
66
+
67
+ if ( viewAttributes.styles ) {
68
+ for ( const style of Object.keys( viewAttributes.styles ) ) {
69
+ writer.removeStyle( style, viewElement );
70
+ }
71
+ }
72
+
73
+ if ( viewAttributes.classes ) {
74
+ writer.removeClass( viewAttributes.classes, viewElement );
75
+ }
76
+ }
77
+
35
78
  /**
36
79
  * Merges view element attribute objects.
37
80
  *
@@ -45,7 +88,7 @@ export function mergeViewElementAttributes( target, source ) {
45
88
  for ( const key in source ) {
46
89
  // Merge classes.
47
90
  if ( Array.isArray( source[ key ] ) ) {
48
- result[ key ] = Array.from( new Set( [ ...target[ key ], ...source[ key ] ] ) );
91
+ result[ key ] = Array.from( new Set( [ ...( target[ key ] || [] ), ...source[ key ] ] ) );
49
92
  }
50
93
 
51
94
  // Merge attributes or styles.
package/src/converters.js CHANGED
@@ -8,7 +8,11 @@
8
8
  */
9
9
 
10
10
  import { toWidget } from 'ckeditor5/src/widget';
11
- import { setViewAttributes, mergeViewElementAttributes } from './conversionutils';
11
+ import {
12
+ setViewAttributes,
13
+ mergeViewElementAttributes,
14
+ updateViewAttributes
15
+ } from './conversionutils';
12
16
 
13
17
  /**
14
18
  * View-to-model conversion helper for object elements.
@@ -28,7 +32,7 @@ export function viewToModelObjectConverter( { model: modelName } ) {
28
32
  }
29
33
 
30
34
  /**
31
- * Conversion helper converting object element to HTML object widget.
35
+ * Conversion helper converting an object element to an HTML object widget.
32
36
  *
33
37
  * @param {module:core/editor/editor~Editor} editor
34
38
  * @param {module:html-support/dataschema~DataSchemaInlineElementDefinition} definition
@@ -37,27 +41,27 @@ export function viewToModelObjectConverter( { model: modelName } ) {
37
41
  export function toObjectWidgetConverter( editor, { view: viewName, isInline } ) {
38
42
  const t = editor.t;
39
43
 
40
- return ( modelElement, { writer, consumable } ) => {
44
+ return ( modelElement, { writer } ) => {
41
45
  const widgetLabel = t( 'HTML object' );
42
46
 
43
- // Widget cannot be a raw element because the widget system would not be able
44
- // to add its UI to it. Thus, we need separate view container.
45
- const viewContainer = writer.createContainerElement( isInline ? 'span' : 'div', {
46
- class: 'html-object-embed',
47
- 'data-html-object-embed-label': widgetLabel
48
- }, {
49
- isAllowedInsideAttributeElement: isInline
50
- } );
51
-
52
47
  const viewElement = createObjectView( viewName, modelElement, writer );
48
+ const viewAttributes = modelElement.getAttribute( 'htmlAttributes' );
49
+
53
50
  writer.addClass( 'html-object-embed__content', viewElement );
54
51
 
55
- const viewAttributes = modelElement.getAttribute( 'htmlAttributes' );
56
- if ( viewAttributes && consumable.consume( modelElement, `attribute:htmlAttributes:${ modelElement.name }` ) ) {
52
+ if ( viewAttributes ) {
57
53
  setViewAttributes( writer, viewAttributes, viewElement );
58
54
  }
59
55
 
60
- writer.insert( writer.createPositionAt( viewContainer, 0 ), viewElement );
56
+ // Widget cannot be a raw element because the widget system would not be able
57
+ // to add its UI to it. Thus, we need separate view container.
58
+ const viewContainer = writer.createContainerElement( isInline ? 'span' : 'div',
59
+ {
60
+ class: 'html-object-embed',
61
+ 'data-html-object-embed-label': widgetLabel
62
+ },
63
+ viewElement
64
+ );
61
65
 
62
66
  return toWidget( viewContainer, writer, { widgetLabel } );
63
67
  };
@@ -87,7 +91,19 @@ export function createObjectView( viewName, modelElement, writer ) {
87
91
  export function viewToAttributeInlineConverter( { view: viewName, model: attributeKey }, dataFilter ) {
88
92
  return dispatcher => {
89
93
  dispatcher.on( `element:${ viewName }`, ( evt, data, conversionApi ) => {
90
- const viewAttributes = dataFilter._consumeAllowedAttributes( data.viewItem, conversionApi );
94
+ let viewAttributes = dataFilter.processViewAttributes( data.viewItem, conversionApi );
95
+
96
+ // Do not apply the attribute if the element itself is already consumed and there are no view attributes to store.
97
+ if ( !viewAttributes && !conversionApi.consumable.test( data.viewItem, { name: true } ) ) {
98
+ return;
99
+ }
100
+
101
+ // Otherwise, we might need to convert it to an empty object just to preserve element itself,
102
+ // for example `<cite>` => <$text htmlCite="{}">.
103
+ viewAttributes = viewAttributes || {};
104
+
105
+ // Consume the element itself if it wasn't consumed by any other converter.
106
+ conversionApi.consumable.consume( data.viewItem, { name: true } );
91
107
 
92
108
  // Since we are converting to attribute we need a range on which we will set the attribute.
93
109
  // If the range is not created yet, we will create it.
@@ -101,7 +117,7 @@ export function viewToAttributeInlineConverter( { view: viewName, model: attribu
101
117
  // Node's children are converted recursively, so node can already include model attribute.
102
118
  // We want to extend it, not replace.
103
119
  const nodeAttributes = node.getAttribute( attributeKey );
104
- const attributesToAdd = mergeViewElementAttributes( viewAttributes || {}, nodeAttributes || {} );
120
+ const attributesToAdd = mergeViewElementAttributes( viewAttributes, nodeAttributes || {} );
105
121
 
106
122
  conversionApi.writer.setAttribute( attributeKey, attributesToAdd, node );
107
123
  }
@@ -143,11 +159,15 @@ export function attributeToViewInlineConverter( { priority, view: viewName } ) {
143
159
  export function viewToModelBlockAttributeConverter( { view: viewName }, dataFilter ) {
144
160
  return dispatcher => {
145
161
  dispatcher.on( `element:${ viewName }`, ( evt, data, conversionApi ) => {
146
- if ( !data.modelRange ) {
162
+ // Converting an attribute of an element that has not been converted to anything does not make sense
163
+ // because there will be nowhere to set that attribute on. At this stage, the element should've already
164
+ // been converted. A collapsed range can show up in to-do lists (<input>) or complex widgets (e.g. table).
165
+ // (https://github.com/ckeditor/ckeditor5/issues/11000).
166
+ if ( !data.modelRange || data.modelRange.isCollapsed ) {
147
167
  return;
148
168
  }
149
169
 
150
- const viewAttributes = dataFilter._consumeAllowedAttributes( data.viewItem, conversionApi );
170
+ const viewAttributes = dataFilter.processViewAttributes( data.viewItem, conversionApi );
151
171
 
152
172
  if ( viewAttributes ) {
153
173
  conversionApi.writer.setAttribute( 'htmlAttributes', viewAttributes, data.modelRange );
@@ -166,16 +186,15 @@ export function viewToModelBlockAttributeConverter( { view: viewName }, dataFilt
166
186
  export function modelToViewBlockAttributeConverter( { model: modelName } ) {
167
187
  return dispatcher => {
168
188
  dispatcher.on( `attribute:htmlAttributes:${ modelName }`, ( evt, data, conversionApi ) => {
169
- const viewAttributes = data.attributeNewValue;
170
-
171
189
  if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
172
190
  return;
173
191
  }
174
192
 
193
+ const { attributeOldValue, attributeNewValue } = data;
175
194
  const viewWriter = conversionApi.writer;
176
195
  const viewElement = conversionApi.mapper.toViewElement( data.item );
177
196
 
178
- setViewAttributes( viewWriter, viewAttributes, viewElement );
197
+ updateViewAttributes( viewWriter, attributeOldValue, attributeNewValue, viewElement );
179
198
  } );
180
199
  };
181
200
  }
package/src/datafilter.js CHANGED
@@ -53,6 +53,9 @@ import '../theme/datafilter.css';
53
53
  * }
54
54
  * } );
55
55
  *
56
+ * To apply the information about allowed and disallowed attributes in custom integration plugin,
57
+ * use the {@link module:html-support/datafilter~DataFilter#processViewAttributes `processViewAttributes()`} method.
58
+ *
56
59
  * @extends module:core/plugin~Plugin
57
60
  */
58
61
  export default class DataFilter extends Plugin {
@@ -106,8 +109,18 @@ export default class DataFilter extends Plugin {
106
109
  */
107
110
  this._dataInitialized = false;
108
111
 
112
+ /**
113
+ * Cached map of coupled attributes. Keys are the feature attributes names
114
+ * and values are arrays with coupled GHS attributes names.
115
+ *
116
+ * @private
117
+ * @member {Map.<String,Array>}
118
+ */
119
+ this._coupledAttributes = null;
120
+
109
121
  this._registerElementsAfterInit();
110
122
  this._registerElementHandlers();
123
+ this._registerModelPostFixer();
111
124
  }
112
125
 
113
126
  /**
@@ -167,6 +180,9 @@ export default class DataFilter extends Plugin {
167
180
  if ( this._dataInitialized ) {
168
181
  this._fireRegisterEvent( definition );
169
182
  }
183
+
184
+ // Reset cached map to recalculate it on the next usage.
185
+ this._coupledAttributes = null;
170
186
  }
171
187
  }
172
188
 
@@ -208,17 +224,30 @@ export default class DataFilter extends Plugin {
208
224
  }
209
225
 
210
226
  /**
211
- * Matches and consumes allowed and disallowed view attributes and returns the allowed ones.
227
+ * Processes all allowed and disallowed attributes on the view element by consuming them and returning the allowed ones.
212
228
  *
213
- * @protected
229
+ * This method applies the configuration set up by {@link #allowAttributes `allowAttributes()`}
230
+ * and {@link #disallowAttributes `disallowAttributes()`} over the given view element by consuming relevant attributes.
231
+ * It returns the allowed attributes that were found on the given view element for further processing by integration code.
232
+ *
233
+ * dispatcher.on( 'element:myElement', ( evt, data, conversionApi ) => {
234
+ * // Get rid of disallowed and extract all allowed attributes from a viewElement.
235
+ * const viewAttributes = dataFilter.processViewAttributes( data.viewItem, conversionApi );
236
+ * // Do something with them, i.e. store inside a model as a dictionary.
237
+ * if ( viewAttributes ) {
238
+ * conversionApi.writer.setAttribute( 'htmlAttributesOfMyElement', viewAttributes, data.modelRange );
239
+ * }
240
+ * } );
241
+ *
242
+ * @see module:engine/conversion/viewconsumable~ViewConsumable#consume
214
243
  * @param {module:engine/view/element~Element} viewElement
215
- * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
244
+ * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi
216
245
  * @returns {Object} [result]
217
246
  * @returns {Object} result.attributes Set with matched attribute names.
218
247
  * @returns {Object} result.styles Set with matched style names.
219
248
  * @returns {Array.<String>} result.classes Set with matched class names.
220
249
  */
221
- _consumeAllowedAttributes( viewElement, conversionApi ) {
250
+ processViewAttributes( viewElement, conversionApi ) {
222
251
  // Make sure that the disabled attributes are handled before the allowed attributes are called.
223
252
  // For example, for block images the <figure> converter triggers conversion for <img> first and then for other elements, i.e. <a>.
224
253
  consumeAttributes( viewElement, conversionApi, this._disallowedAttributes );
@@ -240,10 +269,14 @@ export default class DataFilter extends Plugin {
240
269
  this._fireRegisterEvent( definition );
241
270
  }
242
271
  }, {
243
- // With high priority listener we are able to register elements right before
244
- // running data conversion. Make also sure that priority is higher than the one
245
- // used by `RealTimeCollaborationClient`, as RTC is stopping event propagation.
246
- priority: priorities.get( 'high' ) + 1
272
+ // With highest priority listener we are able to register elements right before
273
+ // running data conversion. Also:
274
+ // * Make sure that priority is higher than the one used by `RealTimeCollaborationClient`,
275
+ // as RTC is stopping event propagation.
276
+ // * Make sure no other features hook into this event before GHS because otherwise the
277
+ // downcast conversion (for these features) could run before GHS registered its converters
278
+ // (https://github.com/ckeditor/ckeditor5/issues/11356).
279
+ priority: priorities.get( 'highest' ) + 1
247
280
  } );
248
281
  }
249
282
 
@@ -284,6 +317,91 @@ export default class DataFilter extends Plugin {
284
317
  }, { priority: 'lowest' } );
285
318
  }
286
319
 
320
+ /**
321
+ * Registers a model post-fixer that is removing coupled GHS attributes of inline elements. Those attributes
322
+ * are removed if a coupled feature attribute is removed.
323
+ *
324
+ * For example, consider following HTML:
325
+ *
326
+ * <a href="foo.html" id="myId">bar</a>
327
+ *
328
+ * Which would be upcasted to following text node in the model:
329
+ *
330
+ * <$text linkHref="foo.html" htmlA="{ attributes: { id: 'myId' } }">bar</$text>
331
+ *
332
+ * When the user removes the link from that text (using UI), only `linkHref` attribute would be removed:
333
+ *
334
+ * <$text htmlA="{ attributes: { id: 'myId' } }">bar</$text>
335
+ *
336
+ * The `htmlA` attribute would stay in the model and would cause GHS to generate an `<a>` element.
337
+ * This is incorrect from UX point of view, as the user wanted to remove the whole link (not only `href`).
338
+ *
339
+ * @private
340
+ */
341
+ _registerModelPostFixer() {
342
+ const model = this.editor.model;
343
+
344
+ model.document.registerPostFixer( writer => {
345
+ const changes = model.document.differ.getChanges();
346
+ let changed = false;
347
+
348
+ const coupledAttributes = this._getCoupledAttributesMap();
349
+
350
+ for ( const change of changes ) {
351
+ // Handle only attribute removals.
352
+ if ( change.type != 'attribute' || change.attributeNewValue !== null ) {
353
+ continue;
354
+ }
355
+
356
+ // Find a list of coupled GHS attributes.
357
+ const attributeKeys = coupledAttributes.get( change.attributeKey );
358
+
359
+ if ( !attributeKeys ) {
360
+ continue;
361
+ }
362
+
363
+ // Remove the coupled GHS attributes on the same range as the feature attribute was removed.
364
+ for ( const { item } of change.range.getWalker( { shallow: true } ) ) {
365
+ for ( const attributeKey of attributeKeys ) {
366
+ if ( item.hasAttribute( attributeKey ) ) {
367
+ writer.removeAttribute( attributeKey, item );
368
+ changed = true;
369
+ }
370
+ }
371
+ }
372
+ }
373
+
374
+ return changed;
375
+ } );
376
+ }
377
+
378
+ /**
379
+ * Collects the map of coupled attributes. The returned map is keyed by the feature attribute name
380
+ * and coupled GHS attribute names are stored in the value array .
381
+ *
382
+ * @private
383
+ * @returns {Map.<String,Array>}
384
+ */
385
+ _getCoupledAttributesMap() {
386
+ if ( this._coupledAttributes ) {
387
+ return this._coupledAttributes;
388
+ }
389
+
390
+ this._coupledAttributes = new Map();
391
+
392
+ for ( const definition of this._allowedElements ) {
393
+ if ( definition.coupledAttribute && definition.model ) {
394
+ const attributeNames = this._coupledAttributes.get( definition.coupledAttribute );
395
+
396
+ if ( attributeNames ) {
397
+ attributeNames.push( definition.model );
398
+ } else {
399
+ this._coupledAttributes.set( definition.coupledAttribute, [ definition.model ] );
400
+ }
401
+ }
402
+ }
403
+ }
404
+
287
405
  /**
288
406
  * Fires `register` event for the given element definition.
289
407
  *
@@ -308,6 +426,7 @@ export default class DataFilter extends Plugin {
308
426
 
309
427
  schema.register( modelName, definition.modelSchema );
310
428
 
429
+ /* istanbul ignore next: paranoid check */
311
430
  if ( !viewName ) {
312
431
  return;
313
432
  }
@@ -331,8 +450,13 @@ export default class DataFilter extends Plugin {
331
450
  } );
332
451
  conversion.for( 'upcast' ).add( viewToModelBlockAttributeConverter( definition, this ) );
333
452
 
334
- conversion.for( 'editingDowncast' ).elementToElement( {
335
- model: modelName,
453
+ conversion.for( 'editingDowncast' ).elementToStructure( {
454
+ model: {
455
+ name: modelName,
456
+ attributes: [
457
+ 'htmlAttributes'
458
+ ]
459
+ },
336
460
  view: toObjectWidgetConverter( editor, definition )
337
461
  } );
338
462
 
@@ -452,7 +576,7 @@ export default class DataFilter extends Plugin {
452
576
  //
453
577
  // @private
454
578
  // @param {module:engine/view/element~Element} viewElement
455
- // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
579
+ // @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi
456
580
  // @param {module:engine/view/matcher~Matcher Matcher} matcher
457
581
  // @returns {Object} [result]
458
582
  // @returns {Object} result.attributes
@@ -486,7 +610,7 @@ function consumeAttributes( viewElement, conversionApi, matcher ) {
486
610
  //
487
611
  // @private
488
612
  // @param {module:engine/view/element~Element} viewElement
489
- // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
613
+ // @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi
490
614
  // @param {module:engine/view/matcher~Matcher Matcher} matcher
491
615
  // @returns {Array.<Object>} Array with match information about found attributes.
492
616
  function consumeAttributeMatches( viewElement, { consumable }, matcher ) {
@@ -499,9 +623,8 @@ function consumeAttributeMatches( viewElement, { consumable }, matcher ) {
499
623
  // We only want to consume attributes, so element can be still processed by other converters.
500
624
  delete match.match.name;
501
625
 
502
- if ( consumable.consume( viewElement, match.match ) ) {
503
- consumedMatches.push( match );
504
- }
626
+ consumable.consume( viewElement, match.match );
627
+ consumedMatches.push( match );
505
628
  }
506
629
 
507
630
  return consumedMatches;
@@ -511,7 +634,7 @@ function consumeAttributeMatches( viewElement, { consumable }, matcher ) {
511
634
  //
512
635
  // @private
513
636
  // @param {module:engine/view/element~Element} viewElement
514
- // @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable
637
+ // @param {module:engine/conversion/viewconsumable~ViewConsumable} consumable
515
638
  // @param {Object} match
516
639
  function removeConsumedAttributes( consumable, viewElement, match ) {
517
640
  for ( const key of [ 'attributes', 'classes', 'styles' ] ) {
@@ -521,7 +644,8 @@ function removeConsumedAttributes( consumable, viewElement, match ) {
521
644
  continue;
522
645
  }
523
646
 
524
- for ( const value of attributes ) {
647
+ // Iterating over a copy of an array so removing items doesn't influence iteration.
648
+ for ( const value of Array.from( attributes ) ) {
525
649
  if ( !consumable.test( viewElement, ( { [ key ]: [ value ] } ) ) ) {
526
650
  removeItemFromArray( attributes, value );
527
651
  }
package/src/dataschema.js CHANGED
@@ -251,5 +251,8 @@ function testViewName( pattern, viewName ) {
251
251
  * @property {Number} [priority] Element priority. Decides in what order elements are wrapped by
252
252
  * {@link module:engine/view/downcastwriter~DowncastWriter}.
253
253
  * Set by {@link module:html-support/dataschema~DataSchema#registerInlineElement} method.
254
+ * @property {String} [coupledAttribute] The name of the model attribute that generates the same view element. GHS inline attribute
255
+ * will be removed from the model tree as soon as the coupled attribute is removed. See
256
+ * {@link module:html-support/datafilter~DataFilter#_registerModelPostFixer GHS post-fixer} for more details.
254
257
  * @extends module:html-support/dataschema~DataSchemaDefinition
255
258
  */