@ckeditor/ckeditor5-html-support 33.0.0 → 34.2.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,14 +41,15 @@ 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
47
  const viewElement = createObjectView( viewName, modelElement, writer );
48
+ const viewAttributes = modelElement.getAttribute( 'htmlAttributes' );
49
+
44
50
  writer.addClass( 'html-object-embed__content', viewElement );
45
51
 
46
- const viewAttributes = modelElement.getAttribute( 'htmlAttributes' );
47
- if ( viewAttributes && consumable.consume( modelElement, `attribute:htmlAttributes:${ modelElement.name }` ) ) {
52
+ if ( viewAttributes ) {
48
53
  setViewAttributes( writer, viewAttributes, viewElement );
49
54
  }
50
55
 
@@ -55,10 +60,7 @@ export function toObjectWidgetConverter( editor, { view: viewName, isInline } )
55
60
  class: 'html-object-embed',
56
61
  'data-html-object-embed-label': widgetLabel
57
62
  },
58
- viewElement,
59
- {
60
- isAllowedInsideAttributeElement: isInline
61
- }
63
+ viewElement
62
64
  );
63
65
 
64
66
  return toWidget( viewContainer, writer, { widgetLabel } );
@@ -89,7 +91,19 @@ export function createObjectView( viewName, modelElement, writer ) {
89
91
  export function viewToAttributeInlineConverter( { view: viewName, model: attributeKey }, dataFilter ) {
90
92
  return dispatcher => {
91
93
  dispatcher.on( `element:${ viewName }`, ( evt, data, conversionApi ) => {
92
- 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 } );
93
107
 
94
108
  // Since we are converting to attribute we need a range on which we will set the attribute.
95
109
  // If the range is not created yet, we will create it.
@@ -103,7 +117,7 @@ export function viewToAttributeInlineConverter( { view: viewName, model: attribu
103
117
  // Node's children are converted recursively, so node can already include model attribute.
104
118
  // We want to extend it, not replace.
105
119
  const nodeAttributes = node.getAttribute( attributeKey );
106
- const attributesToAdd = mergeViewElementAttributes( viewAttributes || {}, nodeAttributes || {} );
120
+ const attributesToAdd = mergeViewElementAttributes( viewAttributes, nodeAttributes || {} );
107
121
 
108
122
  conversionApi.writer.setAttribute( attributeKey, attributesToAdd, node );
109
123
  }
@@ -145,11 +159,15 @@ export function attributeToViewInlineConverter( { priority, view: viewName } ) {
145
159
  export function viewToModelBlockAttributeConverter( { view: viewName }, dataFilter ) {
146
160
  return dispatcher => {
147
161
  dispatcher.on( `element:${ viewName }`, ( evt, data, conversionApi ) => {
148
- 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 ) {
149
167
  return;
150
168
  }
151
169
 
152
- const viewAttributes = dataFilter._consumeAllowedAttributes( data.viewItem, conversionApi );
170
+ const viewAttributes = dataFilter.processViewAttributes( data.viewItem, conversionApi );
153
171
 
154
172
  if ( viewAttributes ) {
155
173
  conversionApi.writer.setAttribute( 'htmlAttributes', viewAttributes, data.modelRange );
@@ -168,16 +186,15 @@ export function viewToModelBlockAttributeConverter( { view: viewName }, dataFilt
168
186
  export function modelToViewBlockAttributeConverter( { model: modelName } ) {
169
187
  return dispatcher => {
170
188
  dispatcher.on( `attribute:htmlAttributes:${ modelName }`, ( evt, data, conversionApi ) => {
171
- const viewAttributes = data.attributeNewValue;
172
-
173
189
  if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
174
190
  return;
175
191
  }
176
192
 
193
+ const { attributeOldValue, attributeNewValue } = data;
177
194
  const viewWriter = conversionApi.writer;
178
195
  const viewElement = conversionApi.mapper.toViewElement( data.item );
179
196
 
180
- setViewAttributes( viewWriter, viewAttributes, viewElement );
197
+ updateViewAttributes( viewWriter, attributeOldValue, attributeNewValue, viewElement );
181
198
  } );
182
199
  };
183
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 );
@@ -288,6 +317,91 @@ export default class DataFilter extends Plugin {
288
317
  }, { priority: 'lowest' } );
289
318
  }
290
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
+
291
405
  /**
292
406
  * Fires `register` event for the given element definition.
293
407
  *
@@ -312,6 +426,7 @@ export default class DataFilter extends Plugin {
312
426
 
313
427
  schema.register( modelName, definition.modelSchema );
314
428
 
429
+ /* istanbul ignore next: paranoid check */
315
430
  if ( !viewName ) {
316
431
  return;
317
432
  }
@@ -336,7 +451,12 @@ export default class DataFilter extends Plugin {
336
451
  conversion.for( 'upcast' ).add( viewToModelBlockAttributeConverter( definition, this ) );
337
452
 
338
453
  conversion.for( 'editingDowncast' ).elementToStructure( {
339
- model: modelName,
454
+ model: {
455
+ name: modelName,
456
+ attributes: [
457
+ 'htmlAttributes'
458
+ ]
459
+ },
340
460
  view: toObjectWidgetConverter( editor, definition )
341
461
  } );
342
462
 
@@ -432,14 +552,14 @@ export default class DataFilter extends Plugin {
432
552
  * as an event namespace, e.g. `register:span`.
433
553
  *
434
554
  * dataFilter.on( 'register', ( evt, definition ) => {
435
- * editor.schema.register( definition.model, definition.modelSchema );
555
+ * editor.model.schema.register( definition.model, definition.modelSchema );
436
556
  * editor.conversion.elementToElement( { model: definition.model, view: definition.view } );
437
557
  *
438
558
  * evt.stop();
439
559
  * } );
440
560
  *
441
561
  * dataFilter.on( 'register:span', ( evt, definition ) => {
442
- * editor.schema.extend( '$text', { allowAttributes: 'htmlSpan' } );
562
+ * editor.model.schema.extend( '$text', { allowAttributes: 'htmlSpan' } );
443
563
  *
444
564
  * editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'span', model: 'htmlSpan' } );
445
565
  * editor.conversion.for( 'downcast' ).attributeToElement( { view: 'span', model: 'htmlSpan' } );
@@ -456,7 +576,7 @@ export default class DataFilter extends Plugin {
456
576
  //
457
577
  // @private
458
578
  // @param {module:engine/view/element~Element} viewElement
459
- // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
579
+ // @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi
460
580
  // @param {module:engine/view/matcher~Matcher Matcher} matcher
461
581
  // @returns {Object} [result]
462
582
  // @returns {Object} result.attributes
@@ -490,7 +610,7 @@ function consumeAttributes( viewElement, conversionApi, matcher ) {
490
610
  //
491
611
  // @private
492
612
  // @param {module:engine/view/element~Element} viewElement
493
- // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
613
+ // @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi
494
614
  // @param {module:engine/view/matcher~Matcher Matcher} matcher
495
615
  // @returns {Array.<Object>} Array with match information about found attributes.
496
616
  function consumeAttributeMatches( viewElement, { consumable }, matcher ) {
@@ -503,9 +623,8 @@ function consumeAttributeMatches( viewElement, { consumable }, matcher ) {
503
623
  // We only want to consume attributes, so element can be still processed by other converters.
504
624
  delete match.match.name;
505
625
 
506
- if ( consumable.consume( viewElement, match.match ) ) {
507
- consumedMatches.push( match );
508
- }
626
+ consumable.consume( viewElement, match.match );
627
+ consumedMatches.push( match );
509
628
  }
510
629
 
511
630
  return consumedMatches;
@@ -515,7 +634,7 @@ function consumeAttributeMatches( viewElement, { consumable }, matcher ) {
515
634
  //
516
635
  // @private
517
636
  // @param {module:engine/view/element~Element} viewElement
518
- // @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable
637
+ // @param {module:engine/conversion/viewconsumable~ViewConsumable} consumable
519
638
  // @param {Object} match
520
639
  function removeConsumedAttributes( consumable, viewElement, match ) {
521
640
  for ( const key of [ 'attributes', 'classes', 'styles' ] ) {
@@ -525,7 +644,8 @@ function removeConsumedAttributes( consumable, viewElement, match ) {
525
644
  continue;
526
645
  }
527
646
 
528
- 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 ) ) {
529
649
  if ( !consumable.test( viewElement, ( { [ key ]: [ value ] } ) ) ) {
530
650
  removeItemFromArray( attributes, value );
531
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
  */