@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.
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { Plugin } from 'ckeditor5/src/core';
11
+ import { toArray } from 'ckeditor5/src/utils';
11
12
 
12
13
  import DataFilter from './datafilter';
13
14
  import CodeBlockElementSupport from './integrations/codeblock';
@@ -17,6 +18,8 @@ import ImageElementSupport from './integrations/image';
17
18
  import MediaEmbedElementSupport from './integrations/mediaembed';
18
19
  import ScriptElementSupport from './integrations/script';
19
20
  import TableElementSupport from './integrations/table';
21
+ import StyleElementSupport from './integrations/style';
22
+ import DocumentListElementSupport from './integrations/documentlist';
20
23
 
21
24
  /**
22
25
  * The General HTML Support feature.
@@ -46,7 +49,9 @@ export default class GeneralHtmlSupport extends Plugin {
46
49
  ImageElementSupport,
47
50
  MediaEmbedElementSupport,
48
51
  ScriptElementSupport,
49
- TableElementSupport
52
+ TableElementSupport,
53
+ StyleElementSupport,
54
+ DocumentListElementSupport
50
55
  ];
51
56
  }
52
57
 
@@ -61,6 +66,231 @@ export default class GeneralHtmlSupport extends Plugin {
61
66
  dataFilter.loadAllowedConfig( editor.config.get( 'htmlSupport.allow' ) || [] );
62
67
  dataFilter.loadDisallowedConfig( editor.config.get( 'htmlSupport.disallow' ) || [] );
63
68
  }
69
+
70
+ /**
71
+ * Returns a GHS model attribute name related to a given view element name.
72
+ *
73
+ * @protected
74
+ * @param {String} viewElementName A view element name.
75
+ * @returns {String}
76
+ */
77
+ getGhsAttributeNameForElement( viewElementName ) {
78
+ const dataSchema = this.editor.plugins.get( 'DataSchema' );
79
+ const definitions = Array.from( dataSchema.getDefinitionsForView( viewElementName, false ) );
80
+
81
+ if ( definitions && definitions.length && definitions[ 0 ].isInline && !definitions[ 0 ].isObject ) {
82
+ return definitions[ 0 ].model;
83
+ }
84
+
85
+ return 'htmlAttributes';
86
+ }
87
+
88
+ /**
89
+ * Updates GHS model attribute for a specified view element name, so it includes the given class name.
90
+ *
91
+ * @protected
92
+ * @param {String} viewElementName A view element name.
93
+ * @param {String|Array.<String>} className The css class to add.
94
+ * @param {module:engine/model/selection~Selectable} selectable The selection or element to update.
95
+ */
96
+ addModelHtmlClass( viewElementName, className, selectable ) {
97
+ const model = this.editor.model;
98
+ const ghsAttributeName = this.getGhsAttributeNameForElement( viewElementName );
99
+
100
+ model.change( writer => {
101
+ for ( const item of getItemsToUpdateGhsAttribute( model, selectable, ghsAttributeName ) ) {
102
+ modifyGhsAttribute( writer, item, ghsAttributeName, 'classes', classes => {
103
+ for ( const value of toArray( className ) ) {
104
+ classes.add( value );
105
+ }
106
+ } );
107
+ }
108
+ } );
109
+ }
110
+
111
+ /**
112
+ * Updates GHS model attribute for a specified view element name, so it does not include the given class name.
113
+ *
114
+ * @protected
115
+ * @param {String} viewElementName A view element name.
116
+ * @param {String|Array.<String>} className The css class to remove.
117
+ * @param {module:engine/model/selection~Selectable} selectable The selection or element to update.
118
+ */
119
+ removeModelHtmlClass( viewElementName, className, selectable ) {
120
+ const model = this.editor.model;
121
+ const ghsAttributeName = this.getGhsAttributeNameForElement( viewElementName );
122
+
123
+ model.change( writer => {
124
+ for ( const item of getItemsToUpdateGhsAttribute( model, selectable, ghsAttributeName ) ) {
125
+ modifyGhsAttribute( writer, item, ghsAttributeName, 'classes', classes => {
126
+ for ( const value of toArray( className ) ) {
127
+ classes.delete( value );
128
+ }
129
+ } );
130
+ }
131
+ } );
132
+ }
133
+
134
+ /**
135
+ * Updates GHS model attribute for a specified view element name, so it includes the given attribute.
136
+ *
137
+ * @protected
138
+ * @param {String} viewElementName A view element name.
139
+ * @param {Object} attributes The object with attributes to set.
140
+ * @param {module:engine/model/selection~Selectable} selectable The selection or element to update.
141
+ */
142
+ setModelHtmlAttributes( viewElementName, attributes, selectable ) {
143
+ const model = this.editor.model;
144
+ const ghsAttributeName = this.getGhsAttributeNameForElement( viewElementName );
145
+
146
+ model.change( writer => {
147
+ for ( const item of getItemsToUpdateGhsAttribute( model, selectable, ghsAttributeName ) ) {
148
+ modifyGhsAttribute( writer, item, ghsAttributeName, 'attributes', attributesMap => {
149
+ for ( const [ key, value ] of Object.entries( attributes ) ) {
150
+ attributesMap.set( key, value );
151
+ }
152
+ } );
153
+ }
154
+ } );
155
+ }
156
+
157
+ /**
158
+ * Updates GHS model attribute for a specified view element name, so it does not include the given attribute.
159
+ *
160
+ * @protected
161
+ * @param {String} viewElementName A view element name.
162
+ * @param {String|Array.<String>} attributeName The attribute name (or names) to remove.
163
+ * @param {module:engine/model/selection~Selectable} selectable The selection or element to update.
164
+ */
165
+ removeModelHtmlAttributes( viewElementName, attributeName, selectable ) {
166
+ const model = this.editor.model;
167
+ const ghsAttributeName = this.getGhsAttributeNameForElement( viewElementName );
168
+
169
+ model.change( writer => {
170
+ for ( const item of getItemsToUpdateGhsAttribute( model, selectable, ghsAttributeName ) ) {
171
+ modifyGhsAttribute( writer, item, ghsAttributeName, 'attributes', attributesMap => {
172
+ for ( const key of toArray( attributeName ) ) {
173
+ attributesMap.delete( key );
174
+ }
175
+ } );
176
+ }
177
+ } );
178
+ }
179
+
180
+ /**
181
+ * Updates GHS model attribute for a specified view element name, so it includes a given style.
182
+ *
183
+ * @protected
184
+ * @param {String} viewElementName A view element name.
185
+ * @param {Object} styles The object with styles to set.
186
+ * @param {module:engine/model/selection~Selectable} selectable The selection or element to update.
187
+ */
188
+ setModelHtmlStyles( viewElementName, styles, selectable ) {
189
+ const model = this.editor.model;
190
+ const ghsAttributeName = this.getGhsAttributeNameForElement( viewElementName );
191
+
192
+ model.change( writer => {
193
+ for ( const item of getItemsToUpdateGhsAttribute( model, selectable, ghsAttributeName ) ) {
194
+ modifyGhsAttribute( writer, item, ghsAttributeName, 'styles', stylesMap => {
195
+ for ( const [ key, value ] of Object.entries( styles ) ) {
196
+ stylesMap.set( key, value );
197
+ }
198
+ } );
199
+ }
200
+ } );
201
+ }
202
+
203
+ /**
204
+ * Updates GHS model attribute for a specified view element name, so it does not include a given style.
205
+ *
206
+ * @protected
207
+ * @param {String} viewElementName A view element name.
208
+ * @param {String|Array.<String>} properties The style (or styles list) to remove.
209
+ * @param {module:engine/model/selection~Selectable} selectable The selection or element to update.
210
+ */
211
+ removeModelHtmlStyles( viewElementName, properties, selectable ) {
212
+ const model = this.editor.model;
213
+ const ghsAttributeName = this.getGhsAttributeNameForElement( viewElementName );
214
+
215
+ model.change( writer => {
216
+ for ( const item of getItemsToUpdateGhsAttribute( model, selectable, ghsAttributeName ) ) {
217
+ modifyGhsAttribute( writer, item, ghsAttributeName, 'styles', stylesMap => {
218
+ for ( const key of toArray( properties ) ) {
219
+ stylesMap.delete( key );
220
+ }
221
+ } );
222
+ }
223
+ } );
224
+ }
225
+ }
226
+
227
+ // Returns an iterator over an items in the selectable that accept given GHS attribute.
228
+ function* getItemsToUpdateGhsAttribute( model, selectable, ghsAttributeName ) {
229
+ if ( selectable.is( 'documentSelection' ) && selectable.isCollapsed ) {
230
+ if ( model.schema.checkAttributeInSelection( selectable, ghsAttributeName ) ) {
231
+ yield selectable;
232
+ }
233
+ } else {
234
+ for ( const range of getValidRangesForSelectable( model, selectable, ghsAttributeName ) ) {
235
+ yield* range.getItems( { shallow: true } );
236
+ }
237
+ }
238
+ }
239
+
240
+ // Translates a given selectable to an iterable of ranges.
241
+ function getValidRangesForSelectable( model, selectable, ghsAttributeName ) {
242
+ if ( selectable.is( 'node' ) || selectable.is( '$text' ) || selectable.is( '$textProxy' ) ) {
243
+ if ( model.schema.checkAttribute( selectable, ghsAttributeName ) ) {
244
+ return [ model.createRangeOn( selectable ) ];
245
+ } else {
246
+ return [];
247
+ }
248
+ } else {
249
+ return model.schema.getValidRanges( model.createSelection( selectable ).getRanges(), ghsAttributeName );
250
+ }
251
+ }
252
+
253
+ // Updates a GHS attribute on a specified item.
254
+ // @param {module:engine/model/writer~Writer} writer
255
+ // @param {module:engine/model/item~Item|module:engine/model/documentselection~DocumentSelection} item
256
+ // @param {String} ghsAttributeName
257
+ // @param {'classes'|'attributes'|'styles'} subject
258
+ // @param {Function} callback That receives a map or set as an argument and should modify it (add or remove entries).
259
+ function modifyGhsAttribute( writer, item, ghsAttributeName, subject, callback ) {
260
+ const oldValue = item.getAttribute( ghsAttributeName );
261
+ const newValue = {};
262
+
263
+ for ( const kind of [ 'attributes', 'styles', 'classes' ] ) {
264
+ if ( kind != subject ) {
265
+ if ( oldValue && oldValue[ kind ] ) {
266
+ newValue[ kind ] = oldValue[ kind ];
267
+ }
268
+ } else {
269
+ const values = kind == 'classes' ?
270
+ new Set( oldValue && oldValue[ kind ] || [] ) :
271
+ new Map( Object.entries( oldValue && oldValue[ kind ] || {} ) );
272
+
273
+ callback( values );
274
+
275
+ if ( values.size ) {
276
+ newValue[ kind ] = kind == 'classes' ? Array.from( values ) : Object.fromEntries( values );
277
+ }
278
+ }
279
+ }
280
+
281
+ if ( Object.keys( newValue ).length ) {
282
+ if ( item.is( 'documentSelection' ) ) {
283
+ writer.setSelectionAttribute( ghsAttributeName, newValue );
284
+ } else {
285
+ writer.setAttribute( ghsAttributeName, newValue, item );
286
+ }
287
+ } else if ( oldValue ) {
288
+ if ( item.is( 'documentSelection' ) ) {
289
+ writer.removeSelectionAttribute( ghsAttributeName );
290
+ } else {
291
+ writer.removeAttribute( ghsAttributeName, item );
292
+ }
293
+ }
64
294
  }
65
295
 
66
296
  /**
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { Plugin } from 'ckeditor5/src/core';
11
- import { setViewAttributes } from '../conversionutils.js';
11
+ import { updateViewAttributes } from '../conversionutils.js';
12
12
 
13
13
  import DataFilter from '../datafilter';
14
14
 
@@ -79,7 +79,7 @@ function viewToModelCodeBlockAttributeConverter( dataFilter ) {
79
79
  preserveElementAttributes( viewCodeElement, 'htmlContentAttributes' );
80
80
 
81
81
  function preserveElementAttributes( viewElement, attributeName ) {
82
- const viewAttributes = dataFilter._consumeAllowedAttributes( viewElement, conversionApi );
82
+ const viewAttributes = dataFilter.processViewAttributes( viewElement, conversionApi );
83
83
 
84
84
  if ( viewAttributes ) {
85
85
  conversionApi.writer.setAttribute( attributeName, viewAttributes, data.modelRange );
@@ -101,10 +101,11 @@ function modelToViewCodeBlockAttributeConverter() {
101
101
  return;
102
102
  }
103
103
 
104
+ const { attributeOldValue, attributeNewValue } = data;
104
105
  const viewCodeElement = conversionApi.mapper.toViewElement( data.item );
105
106
  const viewPreElement = viewCodeElement.parent;
106
107
 
107
- setViewAttributes( conversionApi.writer, data.attributeNewValue, viewPreElement );
108
+ updateViewAttributes( conversionApi.writer, attributeOldValue, attributeNewValue, viewPreElement );
108
109
  } );
109
110
 
110
111
  dispatcher.on( 'attribute:htmlContentAttributes:codeBlock', ( evt, data, conversionApi ) => {
@@ -112,9 +113,10 @@ function modelToViewCodeBlockAttributeConverter() {
112
113
  return;
113
114
  }
114
115
 
116
+ const { attributeOldValue, attributeNewValue } = data;
115
117
  const viewCodeElement = conversionApi.mapper.toViewElement( data.item );
116
118
 
117
- setViewAttributes( conversionApi.writer, data.attributeNewValue, viewCodeElement );
119
+ updateViewAttributes( conversionApi.writer, attributeOldValue, attributeNewValue, viewCodeElement );
118
120
  } );
119
121
  };
120
122
  }
@@ -0,0 +1,200 @@
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 html-support/integrations/documentlist
8
+ */
9
+
10
+ import { isEqual } from 'lodash-es';
11
+ import { Plugin } from 'ckeditor5/src/core';
12
+ import { setViewAttributes } from '../conversionutils.js';
13
+
14
+ import DataFilter from '../datafilter';
15
+
16
+ /**
17
+ * Provides the General HTML Support integration with the {@link module:list/documentlist~DocumentList Document List} feature.
18
+ *
19
+ * @extends module:core/plugin~Plugin
20
+ */
21
+ export default class DocumentListElementSupport extends Plugin {
22
+ /**
23
+ * @inheritDoc
24
+ */
25
+ static get requires() {
26
+ return [ DataFilter ];
27
+ }
28
+
29
+ /**
30
+ * @inheritDoc
31
+ */
32
+ init() {
33
+ const editor = this.editor;
34
+
35
+ if ( !editor.plugins.has( 'DocumentListEditing' ) ) {
36
+ return;
37
+ }
38
+
39
+ const schema = editor.model.schema;
40
+ const conversion = editor.conversion;
41
+ const dataFilter = editor.plugins.get( DataFilter );
42
+ const documentListEditing = editor.plugins.get( 'DocumentListEditing' );
43
+
44
+ // Register downcast strategy.
45
+ // Note that this must be done before document list editing registers conversion in afterInit.
46
+ documentListEditing.registerDowncastStrategy( {
47
+ scope: 'item',
48
+ attributeName: 'htmlLiAttributes',
49
+
50
+ setAttributeOnDowncast( writer, attributeValue, viewElement ) {
51
+ setViewAttributes( writer, attributeValue, viewElement );
52
+ }
53
+ } );
54
+
55
+ documentListEditing.registerDowncastStrategy( {
56
+ scope: 'list',
57
+ attributeName: 'htmlListAttributes',
58
+
59
+ setAttributeOnDowncast( writer, viewAttributes, viewElement ) {
60
+ setViewAttributes( writer, viewAttributes, viewElement );
61
+ }
62
+ } );
63
+
64
+ dataFilter.on( 'register', ( evt, definition ) => {
65
+ if ( ![ 'ul', 'ol', 'li' ].includes( definition.view ) ) {
66
+ return;
67
+ }
68
+
69
+ evt.stop();
70
+
71
+ // Do not register same converters twice.
72
+ if ( schema.checkAttribute( '$block', 'htmlListAttributes' ) ) {
73
+ return;
74
+ }
75
+
76
+ schema.extend( '$block', { allowAttributes: [ 'htmlListAttributes', 'htmlLiAttributes' ] } );
77
+ schema.extend( '$blockObject', { allowAttributes: [ 'htmlListAttributes', 'htmlLiAttributes' ] } );
78
+ schema.extend( '$container', { allowAttributes: [ 'htmlListAttributes', 'htmlLiAttributes' ] } );
79
+
80
+ conversion.for( 'upcast' ).add( dispatcher => {
81
+ dispatcher.on( 'element:ul', viewToModelListAttributeConverter( 'htmlListAttributes', dataFilter ), { priority: 'low' } );
82
+ dispatcher.on( 'element:ol', viewToModelListAttributeConverter( 'htmlListAttributes', dataFilter ), { priority: 'low' } );
83
+ dispatcher.on( 'element:li', viewToModelListAttributeConverter( 'htmlLiAttributes', dataFilter ), { priority: 'low' } );
84
+ } );
85
+ } );
86
+
87
+ // Make sure that all items in a single list (items at the same level & listType) have the same properties.
88
+ // Note: This is almost an exact copy from DocumentListPropertiesEditing.
89
+ documentListEditing.on( 'postFixer', ( evt, { listNodes, writer } ) => {
90
+ const previousNodesByIndent = []; // Last seen nodes of lower indented lists.
91
+
92
+ for ( const { node, previous } of listNodes ) {
93
+ // For the first list block there is nothing to compare with.
94
+ if ( !previous ) {
95
+ continue;
96
+ }
97
+
98
+ const nodeIndent = node.getAttribute( 'listIndent' );
99
+ const previousNodeIndent = previous.getAttribute( 'listIndent' );
100
+
101
+ let previousNodeInList = null; // It's like `previous` but has the same indent as current node.
102
+
103
+ // Let's find previous node for the same indent.
104
+ // We're going to need that when we get back to previous indent.
105
+ if ( nodeIndent > previousNodeIndent ) {
106
+ previousNodesByIndent[ previousNodeIndent ] = previous;
107
+ }
108
+ // Restore the one for given indent.
109
+ else if ( nodeIndent < previousNodeIndent ) {
110
+ previousNodeInList = previousNodesByIndent[ nodeIndent ];
111
+ previousNodesByIndent.length = nodeIndent;
112
+ }
113
+ // Same indent.
114
+ else {
115
+ previousNodeInList = previous;
116
+ }
117
+
118
+ // This is a first item of a nested list.
119
+ if ( !previousNodeInList ) {
120
+ continue;
121
+ }
122
+
123
+ if ( previousNodeInList.getAttribute( 'listType' ) == node.getAttribute( 'listType' ) ) {
124
+ const value = previousNodeInList.getAttribute( 'htmlListAttributes' );
125
+
126
+ if ( !isEqual( node.getAttribute( 'htmlListAttributes' ), value ) ) {
127
+ writer.setAttribute( 'htmlListAttributes', value, node );
128
+ evt.return = true;
129
+ }
130
+ }
131
+
132
+ if ( previousNodeInList.getAttribute( 'listItemId' ) == node.getAttribute( 'listItemId' ) ) {
133
+ const value = previousNodeInList.getAttribute( 'htmlLiAttributes' );
134
+
135
+ if ( !isEqual( node.getAttribute( 'htmlLiAttributes' ), value ) ) {
136
+ writer.setAttribute( 'htmlLiAttributes', value, node );
137
+ evt.return = true;
138
+ }
139
+ }
140
+ }
141
+ } );
142
+ }
143
+
144
+ /**
145
+ * @inheritDoc
146
+ */
147
+ afterInit() {
148
+ const editor = this.editor;
149
+
150
+ if ( !editor.commands.get( 'indentList' ) ) {
151
+ return;
152
+ }
153
+
154
+ // Reset list attributes after indenting list items.
155
+ this.listenTo( editor.commands.get( 'indentList' ), 'afterExecute', ( evt, changedBlocks ) => {
156
+ editor.model.change( writer => {
157
+ for ( const node of changedBlocks ) {
158
+ // Just reset the attribute.
159
+ // If there is a previous indented list that this node should be merged into,
160
+ // the postfixer will unify all the attributes of both sub-lists.
161
+ writer.setAttribute( 'htmlListAttributes', {}, node );
162
+ }
163
+ } );
164
+ } );
165
+ }
166
+ }
167
+
168
+ // View-to-model conversion helper preserving allowed attributes on {@link TODO}
169
+ // feature model element.
170
+ //
171
+ // @private
172
+ // @param {String} attributeName
173
+ // @param {module:html-support/datafilter~DataFilter} dataFilter
174
+ // @returns {Function} Returns a conversion callback.
175
+ function viewToModelListAttributeConverter( attributeName, dataFilter ) {
176
+ return ( evt, data, conversionApi ) => {
177
+ const viewElement = data.viewItem;
178
+
179
+ if ( !data.modelRange ) {
180
+ Object.assign( data, conversionApi.convertChildren( data.viewItem, data.modelCursor ) );
181
+ }
182
+
183
+ const viewAttributes = dataFilter.processViewAttributes( viewElement, conversionApi );
184
+
185
+ for ( const item of data.modelRange.getItems( { shallow: true } ) ) {
186
+ // Apply only to list item blocks.
187
+ if ( !item.hasAttribute( 'listItemId' ) ) {
188
+ continue;
189
+ }
190
+
191
+ // Set list attributes only on same level items, those nested deeper are already handled
192
+ // by the recursive conversion.
193
+ if ( item.hasAttribute( attributeName ) ) {
194
+ continue;
195
+ }
196
+
197
+ conversionApi.writer.setAttribute( attributeName, viewAttributes || {}, item );
198
+ }
199
+ };
200
+ }
@@ -111,10 +111,19 @@ export default class DualContentModelElementSupport extends Plugin {
111
111
  * @returns {Boolean}
112
112
  */
113
113
  _hasBlockContent( viewElement ) {
114
- const blockElements = this.editor.editing.view.domConverter.blockElements;
114
+ const view = this.editor.editing.view;
115
+ const blockElements = view.domConverter.blockElements;
116
+
117
+ // Traversing the viewElement subtree looking for block elements.
118
+ // Especially for the cases like <div><a href="#"><p>foo</p></a></div>.
119
+ // https://github.com/ckeditor/ckeditor5/issues/11513
120
+ for ( const viewItem of view.createRangeIn( viewElement ).getItems() ) {
121
+ if ( viewItem.is( 'element' ) && blockElements.includes( viewItem.name ) ) {
122
+ return true;
123
+ }
124
+ }
115
125
 
116
- return Array.from( viewElement.getChildren() )
117
- .some( node => blockElements.includes( node.name ) );
126
+ return false;
118
127
  }
119
128
 
120
129
  /**
@@ -10,7 +10,10 @@
10
10
  import { Plugin } from 'ckeditor5/src/core';
11
11
 
12
12
  import DataFilter from '../datafilter';
13
- import { setViewAttributes } from '../conversionutils.js';
13
+ import {
14
+ setViewAttributes,
15
+ updateViewAttributes
16
+ } from '../conversionutils.js';
14
17
 
15
18
  /**
16
19
  * Provides the General HTML Support integration with the {@link module:image/image~Image Image} feature.
@@ -40,6 +43,10 @@ export default class ImageElementSupport extends Plugin {
40
43
  const conversion = editor.conversion;
41
44
  const dataFilter = editor.plugins.get( DataFilter );
42
45
 
46
+ dataFilter.on( 'register:figure', () => {
47
+ conversion.for( 'upcast' ).add( viewToModelFigureAttributeConverter( dataFilter ) );
48
+ } );
49
+
43
50
  dataFilter.on( 'register:img', ( evt, definition ) => {
44
51
  if ( definition.model !== 'imageBlock' && definition.model !== 'imageInline' ) {
45
52
  return;
@@ -84,36 +91,55 @@ export default class ImageElementSupport extends Plugin {
84
91
  function viewToModelImageAttributeConverter( dataFilter ) {
85
92
  return dispatcher => {
86
93
  dispatcher.on( 'element:img', ( evt, data, conversionApi ) => {
94
+ if ( !data.modelRange ) {
95
+ return;
96
+ }
97
+
87
98
  const viewImageElement = data.viewItem;
88
99
  const viewContainerElement = viewImageElement.parent;
89
100
 
90
101
  preserveElementAttributes( viewImageElement, 'htmlAttributes' );
91
102
 
92
- if ( viewContainerElement.is( 'element', 'figure' ) ) {
93
- preserveElementAttributes( viewContainerElement, 'htmlFigureAttributes' );
94
- } else if ( viewContainerElement.is( 'element', 'a' ) ) {
103
+ if ( viewContainerElement.is( 'element', 'a' ) ) {
95
104
  preserveLinkAttributes( viewContainerElement );
96
105
  }
97
106
 
98
107
  function preserveElementAttributes( viewElement, attributeName ) {
99
- const viewAttributes = dataFilter._consumeAllowedAttributes( viewElement, conversionApi );
108
+ const viewAttributes = dataFilter.processViewAttributes( viewElement, conversionApi );
100
109
 
101
110
  if ( viewAttributes ) {
102
111
  conversionApi.writer.setAttribute( attributeName, viewAttributes, data.modelRange );
103
112
  }
104
113
  }
105
114
 
106
- // For a block image, we want to preserve the attributes on our own.
107
- // The inline image attributes will be handled by the GHS automatically.
108
115
  function preserveLinkAttributes( viewContainerElement ) {
109
116
  if ( data.modelRange && data.modelRange.getContainedElement().is( 'element', 'imageBlock' ) ) {
110
117
  preserveElementAttributes( viewContainerElement, 'htmlLinkAttributes' );
111
118
  }
119
+ }
120
+ }, { priority: 'low' } );
121
+ };
122
+ }
112
123
 
113
- // If we're in a link, then the `<figure>` element should be one level higher.
114
- if ( viewContainerElement.parent.is( 'element', 'figure' ) ) {
115
- preserveElementAttributes( viewContainerElement.parent, 'htmlFigureAttributes' );
116
- }
124
+ // View-to-model conversion helper preserving allowed attributes on {@link module:image/image~Image Image}
125
+ // feature model element from figure view element.
126
+ //
127
+ // @private
128
+ // @param {module:html-support/datafilter~DataFilter} dataFilter
129
+ // @returns {Function} Returns a conversion callback.
130
+ function viewToModelFigureAttributeConverter( dataFilter ) {
131
+ return dispatcher => {
132
+ dispatcher.on( 'element:figure', ( evt, data, conversionApi ) => {
133
+ const viewFigureElement = data.viewItem;
134
+
135
+ if ( !data.modelRange || !viewFigureElement.hasClass( 'image' ) ) {
136
+ return;
137
+ }
138
+
139
+ const viewAttributes = dataFilter.processViewAttributes( viewFigureElement, conversionApi );
140
+
141
+ if ( viewAttributes ) {
142
+ conversionApi.writer.setAttribute( 'htmlFigureAttributes', viewAttributes, data.modelRange );
117
143
  }
118
144
  }, { priority: 'low' } );
119
145
  };
@@ -130,7 +156,7 @@ function modelToViewImageAttributeConverter() {
130
156
 
131
157
  addBlockAttributeConversion( 'img', 'htmlAttributes' );
132
158
  addBlockAttributeConversion( 'figure', 'htmlFigureAttributes' );
133
- addBlockImageLinkAttributeConversion();
159
+ addBlockAttributeConversion( 'a', 'htmlLinkAttributes' );
134
160
 
135
161
  function addInlineAttributeConversion( attributeName ) {
136
162
  dispatcher.on( `attribute:${ attributeName }:imageInline`, ( evt, data, conversionApi ) => {
@@ -138,38 +164,42 @@ function modelToViewImageAttributeConverter() {
138
164
  return;
139
165
  }
140
166
 
167
+ const { attributeOldValue, attributeNewValue } = data;
141
168
  const viewElement = conversionApi.mapper.toViewElement( data.item );
142
169
 
143
- setViewAttributes( conversionApi.writer, data.attributeNewValue, viewElement );
170
+ updateViewAttributes( conversionApi.writer, attributeOldValue, attributeNewValue, viewElement );
144
171
  }, { priority: 'low' } );
145
172
  }
146
173
 
147
174
  function addBlockAttributeConversion( elementName, attributeName ) {
148
175
  dispatcher.on( `attribute:${ attributeName }:imageBlock`, ( evt, data, conversionApi ) => {
149
- if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
176
+ if ( !conversionApi.consumable.test( data.item, evt.name ) ) {
150
177
  return;
151
178
  }
152
179
 
180
+ const { attributeOldValue, attributeNewValue } = data;
153
181
  const containerElement = conversionApi.mapper.toViewElement( data.item );
154
182
  const viewElement = getDescendantElement( conversionApi.writer, containerElement, elementName );
155
183
 
156
- setViewAttributes( conversionApi.writer, data.attributeNewValue, viewElement );
184
+ if ( viewElement ) {
185
+ updateViewAttributes( conversionApi.writer, attributeOldValue, attributeNewValue, viewElement );
186
+ conversionApi.consumable.consume( data.item, evt.name );
187
+ }
157
188
  }, { priority: 'low' } );
158
- }
159
189
 
160
- // To have a link element in the view, we need to attach a converter to the `linkHref` attribute.
161
- // Doing this directly on `htmlLinkAttributes` will fail, as the link wrapper is not yet called at that moment.
162
- function addBlockImageLinkAttributeConversion( ) {
163
- dispatcher.on( 'attribute:linkHref:imageBlock', ( evt, data, conversionApi ) => {
164
- if ( !conversionApi.consumable.consume( data.item, 'attribute:htmlLinkAttributes:imageBlock' ) ) {
165
- return;
166
- }
190
+ if ( elementName === 'a' ) {
191
+ // To have a link element in the view, we need to attach a converter to the `linkHref` attribute as well.
192
+ dispatcher.on( 'attribute:linkHref:imageBlock', ( evt, data, conversionApi ) => {
193
+ if ( !conversionApi.consumable.consume( data.item, 'attribute:htmlLinkAttributes:imageBlock' ) ) {
194
+ return;
195
+ }
167
196
 
168
- const containerElement = conversionApi.mapper.toViewElement( data.item );
169
- const viewElement = getDescendantElement( conversionApi.writer, containerElement, 'a' );
197
+ const containerElement = conversionApi.mapper.toViewElement( data.item );
198
+ const viewElement = getDescendantElement( conversionApi.writer, containerElement, 'a' );
170
199
 
171
- setViewAttributes( conversionApi.writer, data.item.getAttribute( 'htmlLinkAttributes' ), viewElement );
172
- }, { priority: 'low' } );
200
+ setViewAttributes( conversionApi.writer, data.item.getAttribute( 'htmlLinkAttributes' ), viewElement );
201
+ }, { priority: 'low' } );
202
+ }
173
203
  }
174
204
  };
175
205
  }