@ckeditor/ckeditor5-engine 29.1.0 → 31.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.
@@ -7,7 +7,7 @@
7
7
  * @module engine/view/domconverter
8
8
  */
9
9
 
10
- /* globals document, Node, Text */
10
+ /* globals document, Node, NodeFilter, DOMParser, Text */
11
11
 
12
12
  import ViewText from './text';
13
13
  import ViewElement from './element';
@@ -24,6 +24,7 @@ import {
24
24
  } from './filler';
25
25
 
26
26
  import global from '@ckeditor/ckeditor5-utils/src/dom/global';
27
+ import { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
27
28
  import indexOf from '@ckeditor/ckeditor5-utils/src/dom/indexof';
28
29
  import getAncestors from '@ckeditor/ckeditor5-utils/src/dom/getancestors';
29
30
  import isText from '@ckeditor/ckeditor5-utils/src/dom/istext';
@@ -31,6 +32,8 @@ import isText from '@ckeditor/ckeditor5-utils/src/dom/istext';
31
32
  const BR_FILLER_REF = BR_FILLER( document ); // eslint-disable-line new-cap
32
33
  const NBSP_FILLER_REF = NBSP_FILLER( document ); // eslint-disable-line new-cap
33
34
  const MARKED_NBSP_FILLER_REF = MARKED_NBSP_FILLER( document ); // eslint-disable-line new-cap
35
+ const UNSAFE_ATTRIBUTE_NAME_PREFIX = 'data-ck-unsafe-attribute-';
36
+ const UNSAFE_ELEMENT_REPLACEMENT_ATTRIBUTE = 'data-ck-unsafe-element';
34
37
 
35
38
  /**
36
39
  * `DomConverter` is a set of tools to do transformations between DOM nodes and view nodes. It also handles
@@ -51,7 +54,12 @@ export default class DomConverter {
51
54
  *
52
55
  * @param {module:engine/view/document~Document} document The view document instance.
53
56
  * @param {Object} options An object with configuration options.
54
- * @param {module:engine/view/filler~BlockFillerMode} [options.blockFillerMode='br'] The type of the block filler to use.
57
+ * @param {module:engine/view/filler~BlockFillerMode} [options.blockFillerMode] The type of the block filler to use.
58
+ * Default value depends on the options.renderingMode:
59
+ * 'nbsp' when options.renderingMode == 'data',
60
+ * 'br' when options.renderingMode == 'editing'.
61
+ * @param {'data'|'editing'} [options.renderingMode='editing'] Whether to leave the View-to-DOM conversion result unchanged
62
+ * or improve editing experience by filtering out interactive data.
55
63
  */
56
64
  constructor( document, options = {} ) {
57
65
  /**
@@ -60,12 +68,19 @@ export default class DomConverter {
60
68
  */
61
69
  this.document = document;
62
70
 
71
+ /**
72
+ * Whether to leave the View-to-DOM conversion result unchanged or improve editing experience by filtering out interactive data.
73
+ *
74
+ * @member {'data'|'editing'} module:engine/view/domconverter~DomConverter#renderingMode
75
+ */
76
+ this.renderingMode = options.renderingMode || 'editing';
77
+
63
78
  /**
64
79
  * The mode of a block filler used by the DOM converter.
65
80
  *
66
81
  * @member {'br'|'nbsp'|'markedNbsp'} module:engine/view/domconverter~DomConverter#blockFillerMode
67
82
  */
68
- this.blockFillerMode = options.blockFillerMode || 'br';
83
+ this.blockFillerMode = options.blockFillerMode || ( this.renderingMode === 'editing' ? 'br' : 'nbsp' );
69
84
 
70
85
  /**
71
86
  * Elements which are considered pre-formatted elements.
@@ -221,6 +236,106 @@ export default class DomConverter {
221
236
  this._viewToDomMapping.set( viewFragment, domFragment );
222
237
  }
223
238
 
239
+ /**
240
+ * Decides whether a given pair of attribute key and value should be passed further down the pipeline.
241
+ *
242
+ * @param {String} attributeKey
243
+ * @param {String} attributeValue
244
+ * @param {String} elementName Element name in lower case.
245
+ * @returns {Boolean}
246
+ */
247
+ shouldRenderAttribute( attributeKey, attributeValue, elementName ) {
248
+ if ( this.renderingMode === 'data' ) {
249
+ return true;
250
+ }
251
+
252
+ attributeKey = attributeKey.toLowerCase();
253
+
254
+ if ( attributeKey.startsWith( 'on' ) ) {
255
+ return false;
256
+ }
257
+
258
+ if (
259
+ attributeKey === 'srcdoc' &&
260
+ attributeValue.match( /\bon\S+\s*=|javascript:|<\s*\/*script/i )
261
+ ) {
262
+ return false;
263
+ }
264
+
265
+ if (
266
+ elementName === 'img' &&
267
+ ( attributeKey === 'src' || attributeKey === 'srcset' )
268
+ ) {
269
+ return true;
270
+ }
271
+
272
+ if ( elementName === 'source' && attributeKey === 'srcset' ) {
273
+ return true;
274
+ }
275
+
276
+ if ( attributeValue.match( /^\s*(javascript:|data:(image\/svg|text\/x?html))/i ) ) {
277
+ return false;
278
+ }
279
+
280
+ return true;
281
+ }
282
+
283
+ /**
284
+ * Set `domElement`'s content using provided `html` argument. Apply necessary filtering for the editing pipeline.
285
+ *
286
+ * @param {Element} domElement DOM element that should have `html` set as its content.
287
+ * @param {String} html Textual representation of the HTML that will be set on `domElement`.
288
+ */
289
+ setContentOf( domElement, html ) {
290
+ // For data pipeline we pass the HTML as-is.
291
+ if ( this.renderingMode === 'data' ) {
292
+ domElement.innerHTML = html;
293
+
294
+ return;
295
+ }
296
+
297
+ const document = new DOMParser().parseFromString( html, 'text/html' );
298
+ const fragment = document.createDocumentFragment();
299
+ const bodyChildNodes = document.body.childNodes;
300
+
301
+ while ( bodyChildNodes.length > 0 ) {
302
+ fragment.appendChild( bodyChildNodes[ 0 ] );
303
+ }
304
+
305
+ const treeWalker = document.createTreeWalker( fragment, NodeFilter.SHOW_ELEMENT );
306
+ const nodes = [];
307
+
308
+ let currentNode;
309
+
310
+ // eslint-disable-next-line no-cond-assign
311
+ while ( currentNode = treeWalker.nextNode() ) {
312
+ nodes.push( currentNode );
313
+ }
314
+
315
+ for ( const currentNode of nodes ) {
316
+ // Go through nodes to remove those that are prohibited in editing pipeline.
317
+ for ( const attributeName of currentNode.getAttributeNames() ) {
318
+ this.setDomElementAttribute( currentNode, attributeName, currentNode.getAttribute( attributeName ) );
319
+ }
320
+
321
+ const elementName = currentNode.tagName.toLowerCase();
322
+
323
+ // There are certain nodes, that should be renamed to <span> in editing pipeline.
324
+ if ( this._shouldRenameElement( elementName ) ) {
325
+ logWarning( 'domconverter-unsafe-element-detected', { unsafeElement: currentNode } );
326
+
327
+ currentNode.replaceWith( this._createReplacementDomElement( elementName, currentNode ) );
328
+ }
329
+ }
330
+
331
+ // Empty the target element.
332
+ while ( domElement.firstChild ) {
333
+ domElement.firstChild.remove();
334
+ }
335
+
336
+ domElement.append( fragment );
337
+ }
338
+
224
339
  /**
225
340
  * Converts the view to the DOM. For all text nodes, not bound elements and document fragments new items will
226
341
  * be created. For bound elements and document fragments the method will return corresponding items.
@@ -257,7 +372,7 @@ export default class DomConverter {
257
372
  domElement = domDocument.createComment( viewNode.getCustomProperty( '$rawContent' ) );
258
373
  } else {
259
374
  // UIElement has its own render() method (see #799).
260
- domElement = viewNode.render( domDocument );
375
+ domElement = viewNode.render( domDocument, this );
261
376
  }
262
377
 
263
378
  if ( options.bind ) {
@@ -267,7 +382,11 @@ export default class DomConverter {
267
382
  return domElement;
268
383
  } else {
269
384
  // Create DOM element.
270
- if ( viewNode.hasAttribute( 'xmlns' ) ) {
385
+ if ( this._shouldRenameElement( viewNode.name ) ) {
386
+ logWarning( 'domconverter-unsafe-element-detected', { unsafeElement: viewNode } );
387
+
388
+ domElement = this._createReplacementDomElement( viewNode.name );
389
+ } else if ( viewNode.hasAttribute( 'xmlns' ) ) {
271
390
  domElement = domDocument.createElementNS( viewNode.getAttribute( 'xmlns' ), viewNode.name );
272
391
  } else {
273
392
  domElement = domDocument.createElement( viewNode.name );
@@ -276,7 +395,7 @@ export default class DomConverter {
276
395
  // RawElement take care of their children in RawElement#render() method which can be customized
277
396
  // (see https://github.com/ckeditor/ckeditor5/issues/4469).
278
397
  if ( viewNode.is( 'rawElement' ) ) {
279
- viewNode.render( domElement );
398
+ viewNode.render( domElement, this );
280
399
  }
281
400
 
282
401
  if ( options.bind ) {
@@ -285,7 +404,7 @@ export default class DomConverter {
285
404
 
286
405
  // Copy element's attributes.
287
406
  for ( const key of viewNode.getAttributeKeys() ) {
288
- domElement.setAttribute( key, viewNode.getAttribute( key ) );
407
+ this.setDomElementAttribute( domElement, key, viewNode.getAttribute( key ), viewNode );
289
408
  }
290
409
  }
291
410
 
@@ -299,6 +418,60 @@ export default class DomConverter {
299
418
  }
300
419
  }
301
420
 
421
+ /**
422
+ * Sets the attribute on a DOM element.
423
+ *
424
+ * **Note**: To remove the attribute, use {@link #removeDomElementAttribute}.
425
+ *
426
+ * @param {HTMLElement} domElement The DOM element the attribute should be set on.
427
+ * @param {String} key The name of the attribute
428
+ * @param {String} value The value of the attribute
429
+ * @param {module:engine/view/element~Element} [relatedViewElement] The view element related to the `domElement` (if there is any).
430
+ * It helps decide whether the attribute set is unsafe. For instance, view elements created via
431
+ * {@link module:engine/view/downcastwriter~DowncastWriter} methods can allow certain attributes that would normally be filtered out.
432
+ */
433
+ setDomElementAttribute( domElement, key, value, relatedViewElement = null ) {
434
+ const shouldRenderAttribute = this.shouldRenderAttribute( key, value, domElement.tagName.toLowerCase() ) ||
435
+ relatedViewElement && relatedViewElement.shouldRenderUnsafeAttribute( key );
436
+
437
+ if ( !shouldRenderAttribute ) {
438
+ logWarning( 'domconverter-unsafe-attribute-detected', { domElement, key, value } );
439
+ }
440
+
441
+ // The old value was safe but the new value is unsafe.
442
+ if ( domElement.hasAttribute( key ) && !shouldRenderAttribute ) {
443
+ domElement.removeAttribute( key );
444
+ }
445
+ // The old value was unsafe (but prefixed) but the new value will be safe (will be unprefixed).
446
+ else if ( domElement.hasAttribute( UNSAFE_ATTRIBUTE_NAME_PREFIX + key ) && shouldRenderAttribute ) {
447
+ domElement.removeAttribute( UNSAFE_ATTRIBUTE_NAME_PREFIX + key );
448
+ }
449
+
450
+ // If the attribute should not be rendered, rename it (instead of removing) to give developers some idea of what
451
+ // is going on (https://github.com/ckeditor/ckeditor5/issues/10801).
452
+ domElement.setAttribute( shouldRenderAttribute ? key : UNSAFE_ATTRIBUTE_NAME_PREFIX + key, value );
453
+ }
454
+
455
+ /**
456
+ * Removes an attribute from a DOM element.
457
+ *
458
+ * **Note**: To set the attribute, use {@link #setDomElementAttribute}.
459
+ *
460
+ * @param {HTMLElement} domElement The DOM element the attribute should be removed from.
461
+ * @param {String} key The name of the attribute.
462
+ */
463
+ removeDomElementAttribute( domElement, key ) {
464
+ // See #_createReplacementDomElement() to learn what this is.
465
+ if ( key == UNSAFE_ELEMENT_REPLACEMENT_ATTRIBUTE ) {
466
+ return;
467
+ }
468
+
469
+ domElement.removeAttribute( key );
470
+
471
+ // See setDomElementAttribute() to learn what this is.
472
+ domElement.removeAttribute( UNSAFE_ATTRIBUTE_NAME_PREFIX + key );
473
+ }
474
+
302
475
  /**
303
476
  * Converts children of the view element to DOM using the
304
477
  * {@link module:engine/view/domconverter~DomConverter#viewToDom} method.
@@ -603,10 +776,10 @@ export default class DomConverter {
603
776
  * If structures are too different and it is not possible to find corresponding position then `null` will be returned.
604
777
  *
605
778
  * @param {Node} domParent DOM position parent.
606
- * @param {Number} domOffset DOM position offset.
779
+ * @param {Number} [domOffset=0] DOM position offset. You can skip it when converting the inline filler node.
607
780
  * @returns {module:engine/view/position~Position} viewPosition View position.
608
781
  */
609
- domPositionToView( domParent, domOffset ) {
782
+ domPositionToView( domParent, domOffset = 0 ) {
610
783
  if ( this.isBlockFiller( domParent ) ) {
611
784
  return this.domPositionToView( domParent.parentNode, indexOf( domParent ) );
612
785
  }
@@ -1373,6 +1546,45 @@ export default class DomConverter {
1373
1546
  _isViewElementWithRawContent( viewElement, options ) {
1374
1547
  return options.withChildren !== false && this._rawContentElementMatcher.match( viewElement );
1375
1548
  }
1549
+
1550
+ /**
1551
+ * Checks whether a given element name should be renamed in a current rendering mode.
1552
+ *
1553
+ * @private
1554
+ * @param {String} elementName The name of view element.
1555
+ * @returns {Boolean}
1556
+ */
1557
+ _shouldRenameElement( elementName ) {
1558
+ return this.renderingMode == 'editing' && elementName.toLowerCase() == 'script';
1559
+ }
1560
+
1561
+ /**
1562
+ * Return a <span> element with a special attribute holding the name of the original element.
1563
+ * Optionally, copy all the attributes of the original element if that element is provided.
1564
+ *
1565
+ * @private
1566
+ * @param {String} elementName The name of view element.
1567
+ * @param {Element} [originalDomElement] The original DOM element to copy attributes and content from.
1568
+ * @returns {Element}
1569
+ */
1570
+ _createReplacementDomElement( elementName, originalDomElement = null ) {
1571
+ const newDomElement = document.createElement( 'span' );
1572
+
1573
+ // Mark the span replacing a script as hidden.
1574
+ newDomElement.setAttribute( UNSAFE_ELEMENT_REPLACEMENT_ATTRIBUTE, elementName );
1575
+
1576
+ if ( originalDomElement ) {
1577
+ while ( originalDomElement.firstChild ) {
1578
+ newDomElement.appendChild( originalDomElement.firstChild );
1579
+ }
1580
+
1581
+ for ( const attributeName of originalDomElement.getAttributeNames() ) {
1582
+ newDomElement.setAttribute( attributeName, originalDomElement.getAttribute( attributeName ) );
1583
+ }
1584
+ }
1585
+
1586
+ return newDomElement;
1587
+ }
1376
1588
  }
1377
1589
 
1378
1590
  // Helper function.
@@ -1435,3 +1647,44 @@ function hasBlockParent( domNode, blockElements ) {
1435
1647
  *
1436
1648
  * @typedef {String} module:engine/view/filler~BlockFillerMode
1437
1649
  */
1650
+
1651
+ /**
1652
+ * The {@link module:engine/view/domconverter~DomConverter} detected a `<script>` element that may disrupt the
1653
+ * {@glink framework/guides/architecture/editing-engine#editing-pipeline editing pipeline} of the editor. To avoid this,
1654
+ * the `<script>` element was renamed to `<span data-ck-unsafe-element="script"></span>`.
1655
+ *
1656
+ * @error domconverter-unsafe-element-detected
1657
+ * @param {module:engine/model/element~Element|HTMLElement} unsafeElement The editing view or DOM element
1658
+ * that was renamed.
1659
+ */
1660
+
1661
+ /**
1662
+ * The {@link module:engine/view/domconverter~DomConverter} detected an interactive attribute in the
1663
+ * {@glink framework/guides/architecture/editing-engine#editing-pipeline editing pipeline}. For the best
1664
+ * editing experience, the attribute was renamed to `data-ck-unsafe-attribute-[original attribute name]`.
1665
+ *
1666
+ * If you are the author of the plugin that generated this attribute and you want it to be preserved
1667
+ * in the editing pipeline, you can configure this when creating the element
1668
+ * using {@link module:engine/view/downcastwriter~DowncastWriter} during the
1669
+ * {@glink framework/guides/architecture/editing-engine#conversion model–view conversion}. Methods such as
1670
+ * {@link module:engine/view/downcastwriter~DowncastWriter#createContainerElement},
1671
+ * {@link module:engine/view/downcastwriter~DowncastWriter#createAttributeElement}, or
1672
+ * {@link module:engine/view/downcastwriter~DowncastWriter#createEmptyElement}
1673
+ * accept an option that will disable filtering of specific attributes:
1674
+ *
1675
+ * const paragraph = writer.createContainerElement( 'p',
1676
+ * {
1677
+ * class: 'clickable-paragraph',
1678
+ * onclick: 'alert( "Paragraph clicked!" )'
1679
+ * },
1680
+ * {
1681
+ * // Make sure the "onclick" attribute will pass through.
1682
+ * renderUnsafeAttributes: [ 'onclick' ]
1683
+ * }
1684
+ * );
1685
+ *
1686
+ * @error domconverter-unsafe-attribute-detected
1687
+ * @param {HTMLElement} domElement The DOM element the attribute was set on.
1688
+ * @param {String} key The original name of the attribute
1689
+ * @param {String} value The value of the original attribute
1690
+ */
@@ -186,6 +186,8 @@ export default class DowncastWriter {
186
186
  * @param {Object} [options] Element's options.
187
187
  * @param {Number} [options.priority] Element's {@link module:engine/view/attributeelement~AttributeElement#priority priority}.
188
188
  * @param {Number|String} [options.id] Element's {@link module:engine/view/attributeelement~AttributeElement#id id}.
189
+ * @param {Array.<String>} [options.renderUnsafeAttributes] A list of attribute names that should be rendered in the editing
190
+ * pipeline even though they would normally be filtered out by unsafe attribute detection mechanisms.
189
191
  * @returns {module:engine/view/attributeelement~AttributeElement} Created element.
190
192
  */
191
193
  createAttributeElement( name, attributes, options = {} ) {
@@ -199,6 +201,10 @@ export default class DowncastWriter {
199
201
  attributeElement._id = options.id;
200
202
  }
201
203
 
204
+ if ( options.renderUnsafeAttributes ) {
205
+ attributeElement._unsafeAttributesToRender.push( ...options.renderUnsafeAttributes );
206
+ }
207
+
202
208
  return attributeElement;
203
209
  }
204
210
 
@@ -222,6 +228,8 @@ export default class DowncastWriter {
222
228
  * @param {Boolean} [options.isAllowedInsideAttributeElement=false] Whether an element is
223
229
  * {@link module:engine/view/element~Element#isAllowedInsideAttributeElement allowed inside an AttributeElement} and can be wrapped
224
230
  * with {@link module:engine/view/attributeelement~AttributeElement} by {@link module:engine/view/downcastwriter~DowncastWriter}.
231
+ * @param {Array.<String>} [options.renderUnsafeAttributes] A list of attribute names that should be rendered in the editing
232
+ * pipeline even though they would normally be filtered out by unsafe attribute detection mechanisms.
225
233
  * @returns {module:engine/view/containerelement~ContainerElement} Created element.
226
234
  */
227
235
  createContainerElement( name, attributes, options = {} ) {
@@ -231,6 +239,10 @@ export default class DowncastWriter {
231
239
  containerElement._isAllowedInsideAttributeElement = options.isAllowedInsideAttributeElement;
232
240
  }
233
241
 
242
+ if ( options.renderUnsafeAttributes ) {
243
+ containerElement._unsafeAttributesToRender.push( ...options.renderUnsafeAttributes );
244
+ }
245
+
234
246
  return containerElement;
235
247
  }
236
248
 
@@ -245,12 +257,19 @@ export default class DowncastWriter {
245
257
  *
246
258
  * @param {String} name Name of the element.
247
259
  * @param {Object} [attributes] Elements attributes.
260
+ * @param {Object} [options] Element's options.
261
+ * @param {Array.<String>} [options.renderUnsafeAttributes] A list of attribute names that should be rendered in the editing
262
+ * pipeline even though they would normally be filtered out by unsafe attribute detection mechanisms.
248
263
  * @returns {module:engine/view/editableelement~EditableElement} Created element.
249
264
  */
250
- createEditableElement( name, attributes ) {
265
+ createEditableElement( name, attributes, options = {} ) {
251
266
  const editableElement = new EditableElement( this.document, name, attributes );
252
267
  editableElement._document = this.document;
253
268
 
269
+ if ( options.renderUnsafeAttributes ) {
270
+ editableElement._unsafeAttributesToRender.push( ...options.renderUnsafeAttributes );
271
+ }
272
+
254
273
  return editableElement;
255
274
  }
256
275
 
@@ -266,6 +285,8 @@ export default class DowncastWriter {
266
285
  * @param {Boolean} [options.isAllowedInsideAttributeElement=true] Whether an element is
267
286
  * {@link module:engine/view/element~Element#isAllowedInsideAttributeElement allowed inside an AttributeElement} and can be wrapped
268
287
  * with {@link module:engine/view/attributeelement~AttributeElement} by {@link module:engine/view/downcastwriter~DowncastWriter}.
288
+ * @param {Array.<String>} [options.renderUnsafeAttributes] A list of attribute names that should be rendered in the editing
289
+ * pipeline even though they would normally be filtered out by unsafe attribute detection mechanisms.
269
290
  * @returns {module:engine/view/emptyelement~EmptyElement} Created element.
270
291
  */
271
292
  createEmptyElement( name, attributes, options = {} ) {
@@ -275,6 +296,10 @@ export default class DowncastWriter {
275
296
  emptyElement._isAllowedInsideAttributeElement = options.isAllowedInsideAttributeElement;
276
297
  }
277
298
 
299
+ if ( options.renderUnsafeAttributes ) {
300
+ emptyElement._unsafeAttributesToRender.push( ...options.renderUnsafeAttributes );
301
+ }
302
+
278
303
  return emptyElement;
279
304
  }
280
305
 
@@ -347,6 +372,8 @@ export default class DowncastWriter {
347
372
  * @param {Boolean} [options.isAllowedInsideAttributeElement=true] Whether an element is
348
373
  * {@link module:engine/view/element~Element#isAllowedInsideAttributeElement allowed inside an AttributeElement} and can be wrapped
349
374
  * with {@link module:engine/view/attributeelement~AttributeElement} by {@link module:engine/view/downcastwriter~DowncastWriter}.
375
+ * @param {Array.<String>} [options.renderUnsafeAttributes] A list of attribute names that should be rendered in the editing
376
+ * pipeline even though they would normally be filtered out by unsafe attribute detection mechanisms.
350
377
  * @returns {module:engine/view/rawelement~RawElement} The created element.
351
378
  */
352
379
  createRawElement( name, attributes, renderFunction, options = {} ) {
@@ -358,6 +385,10 @@ export default class DowncastWriter {
358
385
  rawElement._isAllowedInsideAttributeElement = options.isAllowedInsideAttributeElement;
359
386
  }
360
387
 
388
+ if ( options.renderUnsafeAttributes ) {
389
+ rawElement._unsafeAttributesToRender.push( ...options.renderUnsafeAttributes );
390
+ }
391
+
361
392
  return rawElement;
362
393
  }
363
394
 
@@ -138,6 +138,21 @@ export default class Element extends Node {
138
138
  * @member {Boolean}
139
139
  */
140
140
  this._isAllowedInsideAttributeElement = false;
141
+
142
+ /**
143
+ * A list of attribute names that should be rendered in the editing pipeline even though filtering mechanisms
144
+ * implemented in the {@link module:engine/view/domconverter~DomConverter} (for instance,
145
+ * {@link module:engine/view/domconverter~DomConverter#shouldRenderAttribute}) would filter them out.
146
+ *
147
+ * These attributes can be specified as an option when the element is created by
148
+ * the {@link module:engine/view/downcastwriter~DowncastWriter}. To check whether an unsafe an attribute should
149
+ * be permitted, use the {@link #shouldRenderUnsafeAttribute} method.
150
+ *
151
+ * @private
152
+ * @readonly
153
+ * @member {Array.<String>}
154
+ */
155
+ this._unsafeAttributesToRender = [];
141
156
  }
142
157
 
143
158
  /**
@@ -572,6 +587,19 @@ export default class Element extends Node {
572
587
  ( attributes == '' ? '' : ` ${ attributes }` );
573
588
  }
574
589
 
590
+ /**
591
+ * Decides whether an unsafe attribute is whitelisted and should be rendered in the editing pipeline even though filtering mechanisms
592
+ * like {@link module:engine/view/domconverter~DomConverter#shouldRenderAttribute} say it should not.
593
+ *
594
+ * Unsafe attribute names can be specified when creating an element via {@link module:engine/view/downcastwriter~DowncastWriter}.
595
+ *
596
+ * @param {String} attributeName The name of the attribute to be checked.
597
+ * @returns {Boolean}
598
+ */
599
+ shouldRenderUnsafeAttribute( attributeName ) {
600
+ return this._unsafeAttributesToRender.includes( attributeName );
601
+ }
602
+
575
603
  /**
576
604
  * Clones provided element.
577
605
  *
@@ -235,7 +235,7 @@ function isElementMatching( element, pattern ) {
235
235
  function matchName( pattern, name ) {
236
236
  // If pattern is provided as RegExp - test against this regexp.
237
237
  if ( pattern instanceof RegExp ) {
238
- return pattern.test( name );
238
+ return !!name.match( pattern );
239
239
  }
240
240
 
241
241
  return pattern === name;
@@ -394,7 +394,7 @@ function normalizePatterns( patterns ) {
394
394
  function isKeyMatched( patternKey, itemKey ) {
395
395
  return patternKey === true ||
396
396
  patternKey === itemKey ||
397
- patternKey instanceof RegExp && patternKey.test( itemKey );
397
+ patternKey instanceof RegExp && itemKey.match( patternKey );
398
398
  }
399
399
 
400
400
  // @param {String|RegExp} patternValue A pattern representing a value we want to match.
@@ -408,7 +408,11 @@ function isValueMatched( patternValue, itemKey, valueGetter ) {
408
408
 
409
409
  const itemValue = valueGetter( itemKey );
410
410
 
411
- return patternValue === itemValue || patternValue instanceof RegExp && patternValue.test( itemValue );
411
+ // For now, the reducers are not returning the full tree of properties.
412
+ // Casting to string preserves the old behavior until the root cause is fixed.
413
+ // More can be found in https://github.com/ckeditor/ckeditor5/issues/10399.
414
+ return patternValue === itemValue ||
415
+ patternValue instanceof RegExp && !!String( itemValue ).match( patternValue );
412
416
  }
413
417
 
414
418
  // Checks if attributes of provided element can be matched against provided patterns.
@@ -527,7 +531,7 @@ function matchStyles( patterns, element ) {
527
531
  * name: 'figure',
528
532
  * attributes: [
529
533
  * 'title', // Match `title` attribute (can be empty).
530
- * /^data-*$/, // Match attributes starting with `data-` e.g. `data-foo` with any value (can be empty).
534
+ * /^data-*$/ // Match attributes starting with `data-` e.g. `data-foo` with any value (can be empty).
531
535
  * ]
532
536
  * };
533
537
  *
@@ -537,7 +541,8 @@ function matchStyles( patterns, element ) {
537
541
  * attributes: [
538
542
  * {
539
543
  * key: 'type', // Match `type` as an attribute key.
540
- * value: /^(text|number|date)$/ }, // Match `text`, `number` or `date` values.
544
+ * value: /^(text|number|date)$/ // Match `text`, `number` or `date` values.
545
+ * },
541
546
  * {
542
547
  * key: /^data-.*$/, // Match attributes starting with `data-` e.g. `data-foo`.
543
548
  * value: true // Match any value (can be empty).
@@ -568,7 +573,7 @@ function matchStyles( patterns, element ) {
568
573
  * // Match view element which has matching styles (Object).
569
574
  * const pattern = {
570
575
  * name: 'p',
571
- * attributes: {
576
+ * styles: {
572
577
  * color: /rgb\((\d{1,3}), (\d{1,3}), (\d{1,3})\)/, // Match `color` in RGB format only.
573
578
  * 'font-weight': 600, // Match `font-weight` only if it's `600`.
574
579
  * 'text-decoration': true // Match any text decoration.
@@ -578,19 +583,20 @@ function matchStyles( patterns, element ) {
578
583
  * // Match view element which has matching styles (Array).
579
584
  * const pattern = {
580
585
  * name: 'p',
581
- * attributes: [
586
+ * styles: [
582
587
  * 'color', // Match `color` with any value.
583
- * /^border.*$/, // Match all border properties.
588
+ * /^border.*$/ // Match all border properties.
584
589
  * ]
585
590
  * };
586
591
  *
587
592
  * // Match view element which has matching styles (key-value pairs).
588
593
  * const pattern = {
589
594
  * name: 'p',
590
- * attributes: [
595
+ * styles: [
591
596
  * {
592
- * key: 'color', // Match `color` as an property key.
593
- * value: /rgb\((\d{1,3}), (\d{1,3}), (\d{1,3})\)/, // Match RGB format only.
597
+ * key: 'color', // Match `color` as an property key.
598
+ * value: /rgb\((\d{1,3}), (\d{1,3}), (\d{1,3})\)/ // Match RGB format only.
599
+ * },
594
600
  * {
595
601
  * key: /^border.*$/, // Match any border style.
596
602
  * value: true // Match any value.
@@ -643,6 +649,7 @@ function matchStyles( patterns, element ) {
643
649
  * {
644
650
  * key: 'image', // Match `image` class.
645
651
  * value: true
652
+ * },
646
653
  * {
647
654
  * key: /^image-side-(left|right)$/, // Match `image-side-left` or `image-side-right` class.
648
655
  * value: true
@@ -696,11 +703,9 @@ function matchStyles( patterns, element ) {
696
703
  * @typedef {String|RegExp|Object|Function} module:engine/view/matcher~MatcherPattern
697
704
  *
698
705
  * @property {String|RegExp} [name] View element name to match.
699
- * @property {String|RegExp|Array.<String|RegExp>} [classes] View element's class name(s) to match.
700
- * @property {Object} [styles] Object with key-value pairs representing styles to match.
701
- * Each object key represents style name. Value can be given as `String` or `RegExp`.
702
- * @property {Object} [attributes] Object with key-value pairs representing attributes to match.
703
- * Each object key represents attribute name. Value can be given as `String` or `RegExp`.
706
+ * @property {Boolean|String|RegExp|Object|Array.<String|RegExp|Object>} [classes] View element's classes to match.
707
+ * @property {Boolean|String|RegExp|Object|Array.<String|RegExp|Object>} [styles] View element's styles to match.
708
+ * @property {Boolean|String|RegExp|Object|Array.<String|RegExp|Object>} [attributes] View element's attributes to match.
704
709
  */
705
710
 
706
711
  /**