@ckeditor/ckeditor5-engine 31.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ckeditor/ckeditor5-engine",
3
- "version": "31.0.0",
3
+ "version": "31.1.0",
4
4
  "description": "The editing engine of CKEditor 5 – the best browser-based rich text editor.",
5
5
  "keywords": [
6
6
  "wysiwyg",
@@ -23,30 +23,30 @@
23
23
  ],
24
24
  "main": "src/index.js",
25
25
  "dependencies": {
26
- "@ckeditor/ckeditor5-utils": "^31.0.0",
26
+ "@ckeditor/ckeditor5-utils": "^31.1.0",
27
27
  "lodash-es": "^4.17.15"
28
28
  },
29
29
  "devDependencies": {
30
- "@ckeditor/ckeditor5-basic-styles": "^31.0.0",
31
- "@ckeditor/ckeditor5-block-quote": "^31.0.0",
32
- "@ckeditor/ckeditor5-clipboard": "^31.0.0",
33
- "@ckeditor/ckeditor5-cloud-services": "^31.0.0",
34
- "@ckeditor/ckeditor5-core": "^31.0.0",
35
- "@ckeditor/ckeditor5-editor-classic": "^31.0.0",
36
- "@ckeditor/ckeditor5-enter": "^31.0.0",
37
- "@ckeditor/ckeditor5-essentials": "^31.0.0",
38
- "@ckeditor/ckeditor5-heading": "^31.0.0",
39
- "@ckeditor/ckeditor5-image": "^31.0.0",
40
- "@ckeditor/ckeditor5-link": "^31.0.0",
41
- "@ckeditor/ckeditor5-list": "^31.0.0",
42
- "@ckeditor/ckeditor5-mention": "^31.0.0",
43
- "@ckeditor/ckeditor5-paragraph": "^31.0.0",
44
- "@ckeditor/ckeditor5-table": "^31.0.0",
45
- "@ckeditor/ckeditor5-theme-lark": "^31.0.0",
46
- "@ckeditor/ckeditor5-typing": "^31.0.0",
47
- "@ckeditor/ckeditor5-ui": "^31.0.0",
48
- "@ckeditor/ckeditor5-undo": "^31.0.0",
49
- "@ckeditor/ckeditor5-widget": "^31.0.0",
30
+ "@ckeditor/ckeditor5-basic-styles": "^31.1.0",
31
+ "@ckeditor/ckeditor5-block-quote": "^31.1.0",
32
+ "@ckeditor/ckeditor5-clipboard": "^31.1.0",
33
+ "@ckeditor/ckeditor5-cloud-services": "^31.1.0",
34
+ "@ckeditor/ckeditor5-core": "^31.1.0",
35
+ "@ckeditor/ckeditor5-editor-classic": "^31.1.0",
36
+ "@ckeditor/ckeditor5-enter": "^31.1.0",
37
+ "@ckeditor/ckeditor5-essentials": "^31.1.0",
38
+ "@ckeditor/ckeditor5-heading": "^31.1.0",
39
+ "@ckeditor/ckeditor5-image": "^31.1.0",
40
+ "@ckeditor/ckeditor5-link": "^31.1.0",
41
+ "@ckeditor/ckeditor5-list": "^31.1.0",
42
+ "@ckeditor/ckeditor5-mention": "^31.1.0",
43
+ "@ckeditor/ckeditor5-paragraph": "^31.1.0",
44
+ "@ckeditor/ckeditor5-table": "^31.1.0",
45
+ "@ckeditor/ckeditor5-theme-lark": "^31.1.0",
46
+ "@ckeditor/ckeditor5-typing": "^31.1.0",
47
+ "@ckeditor/ckeditor5-ui": "^31.1.0",
48
+ "@ckeditor/ckeditor5-undo": "^31.1.0",
49
+ "@ckeditor/ckeditor5-widget": "^31.1.0",
50
50
  "webpack": "^4.43.0",
51
51
  "webpack-cli": "^3.3.11"
52
52
  },
@@ -527,7 +527,7 @@ export default class DataController {
527
527
  */
528
528
 
529
529
  /**
530
- * Event fired after {@link #get get() method} has been run.
530
+ * Event fired after the {@link #get get() method} has been run.
531
531
  *
532
532
  * The `get` event is fired by decorated {@link #get} method.
533
533
  * See {@link module:utils/observablemixin~ObservableMixin#decorate} for more information and samples.
@@ -570,18 +570,18 @@ function _getMarkersRelativeToElement( element ) {
570
570
  }
571
571
  }
572
572
 
573
- // Sort the markers in a stable fashion to ensure that the order that they are
573
+ // Sort the markers in a stable fashion to ensure that the order in which they are
574
574
  // added to the model's marker collection does not affect how they are
575
- // downcast. One particular use case that we're targeting here is one where
575
+ // downcast. One particular use case that we are targeting here, is one where
576
576
  // two markers are adjacent but not overlapping, such as an insertion/deletion
577
- // suggestion pair represting the replacement of a range of text. In this
577
+ // suggestion pair representing the replacement of a range of text. In this
578
578
  // case, putting the markers in DOM order causes the first marker's end to be
579
579
  // serialized right after the second marker's start, while putting the markers
580
580
  // in reverse DOM order causes it to be right before the second marker's
581
- // start. So, we sort in a way that ensures non-intersecting ranges are in
581
+ // start. So, we sort these in a way that ensures non-intersecting ranges are in
582
582
  // reverse DOM order, and intersecting ranges are in something approximating
583
583
  // reverse DOM order (since reverse DOM order doesn't have a precise meaning
584
- // when working with intersectng ranges).
584
+ // when working with intersecting ranges).
585
585
  return result.sort( ( [ n1, r1 ], [ n2, r2 ] ) => {
586
586
  if ( r1.end.compareWith( r2.start ) !== 'after' ) {
587
587
  // m1.end <= m2.start -- m1 is entirely <= m2
@@ -67,8 +67,8 @@ const domConverterStub = {
67
67
  * @param {Boolean} [options.renderRawElements=false] When set to `true`, the inner content of each
68
68
  * {@link module:engine/view/rawelement~RawElement} will be printed.
69
69
  * @param {Object} [options.domConverter=null] When set to an actual {@link module:engine/view/domconverter~DomConverter DomConverter}
70
- * instance it lets the conversion go through exactly the same flow the editing view is going, i.e. with view data
71
- * filtering. Otherwise the simple stub is used.
70
+ * instance, it lets the conversion go through exactly the same flow the editing view is going through,
71
+ * i.e. with view data filtering. Otherwise the simple stub is used.
72
72
  * @returns {String} The stringified data.
73
73
  */
74
74
  export function getData( view, options = {} ) {
@@ -253,8 +253,8 @@ setData._parse = parse;
253
253
  * @param {Boolean} [options.renderRawElements=false] When set to `true`, the inner content of each
254
254
  * {@link module:engine/view/rawelement~RawElement} will be printed.
255
255
  * @param {Object} [options.domConverter={}] When set to an actual {@link module:engine/view/domconverter~DomConverter DomConverter}
256
- * instance it lets the conversion go through exactly the same flow the editing view is going, i.e. with view data
257
- * filtering. Otherwise the simple stub is used.
256
+ * instance, it lets the conversion go through exactly the same flow the editing view is going through,
257
+ * i.e. with view data filtering. Otherwise the simple stub is used.
258
258
  * @returns {String} An HTML-like string representing the view.
259
259
  */
260
260
  export function stringify( node, selectionOrPositionOrRange = null, options = {} ) {
@@ -645,8 +645,8 @@ class ViewStringify {
645
645
  * {@link module:engine/view/uielement~UIElement} will be printed.
646
646
  * @param {Boolean} [options.renderRawElements=false] When set to `true`, the inner content of each
647
647
  * @param {Object} [options.domConverter={}] When set to an actual {@link module:engine/view/domconverter~DomConverter DomConverter}
648
- * instance it lets the conversion go through exactly the same flow the editing view is going, i.e. with view data
649
- * filtering. Otherwise the simple stub is used.
648
+ * instance, it lets the conversion go through exactly the same flow the editing view is going through,
649
+ * i.e. with view data filtering. Otherwise the simple stub is used.
650
650
  * {@link module:engine/view/rawelement~RawElement} will be printed.
651
651
  */
652
652
  constructor( root, selection, options ) {
@@ -128,10 +128,10 @@ function tryFixingCollapsedRange( range, schema ) {
128
128
 
129
129
  const nearestSelectionRange = schema.getNearestSelectionRange( originalPosition );
130
130
 
131
- // This might be null ie when editor data is empty or the selection is inside limit element
131
+ // This might be null, i.e. when the editor data is empty or the selection is inside a limit element
132
132
  // that doesn't allow text inside.
133
- // In the first case there is no need to fix the selection range.
134
- // In the second let's go up to the outer selectable element
133
+ // In the first case, there is no need to fix the selection range.
134
+ // In the second, let's go up to the outer selectable element
135
135
  if ( !nearestSelectionRange ) {
136
136
  const ancestorObject = originalPosition.getAncestors().reverse().find( item => schema.isObject( item ) );
137
137
 
@@ -263,35 +263,42 @@ function checkSelectionOnNonLimitElements( start, end, schema ) {
263
263
  return startIsOnBlock || endIsOnBlock;
264
264
  }
265
265
 
266
- // Returns a minimal non-intersecting array of ranges.
267
- //
268
- // @param {Array.<module:engine/model/range~Range>} ranges
269
- // @returns {Array.<module:engine/model/range~Range>}
270
- function mergeIntersectingRanges( ranges ) {
271
- const nonIntersectingRanges = [];
272
-
273
- // First range will always be fine.
274
- nonIntersectingRanges.push( ranges.shift() );
275
-
276
- for ( const range of ranges ) {
277
- const previousRange = nonIntersectingRanges.pop();
278
-
279
- if ( range.isEqual( previousRange ) ) {
280
- // Use only one of two identical ranges.
281
- nonIntersectingRanges.push( previousRange );
282
- } else if ( range.isIntersecting( previousRange ) ) {
283
- // Get the sum of two ranges.
284
- const start = previousRange.start.isAfter( range.start ) ? range.start : previousRange.start;
285
- const end = previousRange.end.isAfter( range.end ) ? previousRange.end : range.end;
286
-
287
- const merged = new Range( start, end );
288
- nonIntersectingRanges.push( merged );
289
- } else {
290
- nonIntersectingRanges.push( previousRange );
291
- nonIntersectingRanges.push( range );
266
+ /**
267
+ * Returns a minimal non-intersecting array of ranges without duplicates.
268
+ *
269
+ * @param {Array.<module:engine/model/range~Range>} Ranges to merge.
270
+ * @returns {Array.<module:engine/model/range~Range>} Array of unique and nonIntersecting ranges.
271
+ */
272
+ export function mergeIntersectingRanges( ranges ) {
273
+ const rangesToMerge = [ ...ranges ];
274
+ const rangeIndexesToRemove = new Set();
275
+ let currentRangeIndex = 1;
276
+
277
+ while ( currentRangeIndex < rangesToMerge.length ) {
278
+ const currentRange = rangesToMerge[ currentRangeIndex ];
279
+ const previousRanges = rangesToMerge.slice( 0, currentRangeIndex );
280
+
281
+ for ( const [ previousRangeIndex, previousRange ] of previousRanges.entries() ) {
282
+ if ( rangeIndexesToRemove.has( previousRangeIndex ) ) {
283
+ continue;
284
+ }
285
+
286
+ if ( currentRange.isEqual( previousRange ) ) {
287
+ rangeIndexesToRemove.add( previousRangeIndex );
288
+ } else if ( currentRange.isIntersecting( previousRange ) ) {
289
+ rangeIndexesToRemove.add( previousRangeIndex );
290
+ rangeIndexesToRemove.add( currentRangeIndex );
291
+
292
+ const mergedRange = currentRange.getJoined( previousRange );
293
+ rangesToMerge.push( mergedRange );
294
+ }
292
295
  }
296
+
297
+ currentRangeIndex++;
293
298
  }
294
299
 
300
+ const nonIntersectingRanges = rangesToMerge.filter( ( _, index ) => !rangeIndexesToRemove.has( index ) );
301
+
295
302
  return nonIntersectingRanges;
296
303
  }
297
304
 
@@ -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
@@ -72,14 +75,6 @@ export default class DomConverter {
72
75
  */
73
76
  this.renderingMode = options.renderingMode || 'editing';
74
77
 
75
- /**
76
- * Main switch for new rendering approach in the editing view.
77
- *
78
- * @protected
79
- * @member {Boolean}
80
- */
81
- this.experimentalRenderingMode = false;
82
-
83
78
  /**
84
79
  * The mode of a block filler used by the DOM converter.
85
80
  *
@@ -242,21 +237,47 @@ export default class DomConverter {
242
237
  }
243
238
 
244
239
  /**
245
- * Decides whether given pair of attribute key and value should be passed further down the pipeline.
240
+ * Decides whether a given pair of attribute key and value should be passed further down the pipeline.
246
241
  *
247
242
  * @param {String} attributeKey
248
243
  * @param {String} attributeValue
244
+ * @param {String} elementName Element name in lower case.
249
245
  * @returns {Boolean}
250
246
  */
251
- shouldRenderAttribute( attributeKey, attributeValue ) {
252
- if ( !this.experimentalRenderingMode || this.renderingMode === 'data' ) {
247
+ shouldRenderAttribute( attributeKey, attributeValue, elementName ) {
248
+ if ( this.renderingMode === 'data' ) {
253
249
  return true;
254
250
  }
255
251
 
256
- return !( attributeKey.toLowerCase().startsWith( 'on' ) ||
257
- attributeValue.match( /(\b)(on\S+)(\s*)=|javascript:|(<\s*)(\/*)script/i ) ||
258
- attributeValue.match( /data:(?!image\/(png|jpeg|gif|webp))/i )
259
- );
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;
260
281
  }
261
282
 
262
283
  /**
@@ -267,7 +288,7 @@ export default class DomConverter {
267
288
  */
268
289
  setContentOf( domElement, html ) {
269
290
  // For data pipeline we pass the HTML as-is.
270
- if ( !this.experimentalRenderingMode || this.renderingMode === 'data' ) {
291
+ if ( this.renderingMode === 'data' ) {
271
292
  domElement.innerHTML = html;
272
293
 
273
294
  return;
@@ -294,17 +315,15 @@ export default class DomConverter {
294
315
  for ( const currentNode of nodes ) {
295
316
  // Go through nodes to remove those that are prohibited in editing pipeline.
296
317
  for ( const attributeName of currentNode.getAttributeNames() ) {
297
- const attributeValue = currentNode.getAttribute( attributeName );
298
-
299
- if ( !this.shouldRenderAttribute( attributeName, attributeValue ) ) {
300
- currentNode.removeAttribute( attributeName );
301
- }
318
+ this.setDomElementAttribute( currentNode, attributeName, currentNode.getAttribute( attributeName ) );
302
319
  }
303
320
 
304
321
  const elementName = currentNode.tagName.toLowerCase();
305
322
 
306
323
  // There are certain nodes, that should be renamed to <span> in editing pipeline.
307
324
  if ( this._shouldRenameElement( elementName ) ) {
325
+ logWarning( 'domconverter-unsafe-element-detected', { unsafeElement: currentNode } );
326
+
308
327
  currentNode.replaceWith( this._createReplacementDomElement( elementName, currentNode ) );
309
328
  }
310
329
  }
@@ -364,6 +383,8 @@ export default class DomConverter {
364
383
  } else {
365
384
  // Create DOM element.
366
385
  if ( this._shouldRenameElement( viewNode.name ) ) {
386
+ logWarning( 'domconverter-unsafe-element-detected', { unsafeElement: viewNode } );
387
+
367
388
  domElement = this._createReplacementDomElement( viewNode.name );
368
389
  } else if ( viewNode.hasAttribute( 'xmlns' ) ) {
369
390
  domElement = domDocument.createElementNS( viewNode.getAttribute( 'xmlns' ), viewNode.name );
@@ -383,13 +404,7 @@ export default class DomConverter {
383
404
 
384
405
  // Copy element's attributes.
385
406
  for ( const key of viewNode.getAttributeKeys() ) {
386
- const value = viewNode.getAttribute( key );
387
-
388
- if ( !this.shouldRenderAttribute( key, value ) ) {
389
- continue;
390
- }
391
-
392
- domElement.setAttribute( key, value );
407
+ this.setDomElementAttribute( domElement, key, viewNode.getAttribute( key ), viewNode );
393
408
  }
394
409
  }
395
410
 
@@ -403,6 +418,60 @@ export default class DomConverter {
403
418
  }
404
419
  }
405
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
+
406
475
  /**
407
476
  * Converts children of the view element to DOM using the
408
477
  * {@link module:engine/view/domconverter~DomConverter#viewToDom} method.
@@ -1479,18 +1548,18 @@ export default class DomConverter {
1479
1548
  }
1480
1549
 
1481
1550
  /**
1482
- * Checks whether given element name should be renamed in a current rendering mode.
1551
+ * Checks whether a given element name should be renamed in a current rendering mode.
1483
1552
  *
1484
1553
  * @private
1485
1554
  * @param {String} elementName The name of view element.
1486
1555
  * @returns {Boolean}
1487
1556
  */
1488
1557
  _shouldRenameElement( elementName ) {
1489
- return this.experimentalRenderingMode && this.renderingMode == 'editing' && elementName == 'script';
1558
+ return this.renderingMode == 'editing' && elementName.toLowerCase() == 'script';
1490
1559
  }
1491
1560
 
1492
1561
  /**
1493
- * Return a <span> element with special attribute holding the name of the original element.
1562
+ * Return a <span> element with a special attribute holding the name of the original element.
1494
1563
  * Optionally, copy all the attributes of the original element if that element is provided.
1495
1564
  *
1496
1565
  * @private
@@ -1502,7 +1571,7 @@ export default class DomConverter {
1502
1571
  const newDomElement = document.createElement( 'span' );
1503
1572
 
1504
1573
  // Mark the span replacing a script as hidden.
1505
- newDomElement.setAttribute( 'data-ck-hidden', elementName );
1574
+ newDomElement.setAttribute( UNSAFE_ELEMENT_REPLACEMENT_ATTRIBUTE, elementName );
1506
1575
 
1507
1576
  if ( originalDomElement ) {
1508
1577
  while ( originalDomElement.firstChild ) {
@@ -1578,3 +1647,44 @@ function hasBlockParent( domNode, blockElements ) {
1578
1647
  *
1579
1648
  * @typedef {String} module:engine/view/filler~BlockFillerMode
1580
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
  *
@@ -531,7 +531,7 @@ function matchStyles( patterns, element ) {
531
531
  * name: 'figure',
532
532
  * attributes: [
533
533
  * 'title', // Match `title` attribute (can be empty).
534
- * /^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).
535
535
  * ]
536
536
  * };
537
537
  *
@@ -541,7 +541,8 @@ function matchStyles( patterns, element ) {
541
541
  * attributes: [
542
542
  * {
543
543
  * key: 'type', // Match `type` as an attribute key.
544
- * value: /^(text|number|date)$/ }, // Match `text`, `number` or `date` values.
544
+ * value: /^(text|number|date)$/ // Match `text`, `number` or `date` values.
545
+ * },
545
546
  * {
546
547
  * key: /^data-.*$/, // Match attributes starting with `data-` e.g. `data-foo`.
547
548
  * value: true // Match any value (can be empty).
@@ -572,7 +573,7 @@ function matchStyles( patterns, element ) {
572
573
  * // Match view element which has matching styles (Object).
573
574
  * const pattern = {
574
575
  * name: 'p',
575
- * attributes: {
576
+ * styles: {
576
577
  * color: /rgb\((\d{1,3}), (\d{1,3}), (\d{1,3})\)/, // Match `color` in RGB format only.
577
578
  * 'font-weight': 600, // Match `font-weight` only if it's `600`.
578
579
  * 'text-decoration': true // Match any text decoration.
@@ -582,19 +583,20 @@ function matchStyles( patterns, element ) {
582
583
  * // Match view element which has matching styles (Array).
583
584
  * const pattern = {
584
585
  * name: 'p',
585
- * attributes: [
586
+ * styles: [
586
587
  * 'color', // Match `color` with any value.
587
- * /^border.*$/, // Match all border properties.
588
+ * /^border.*$/ // Match all border properties.
588
589
  * ]
589
590
  * };
590
591
  *
591
592
  * // Match view element which has matching styles (key-value pairs).
592
593
  * const pattern = {
593
594
  * name: 'p',
594
- * attributes: [
595
+ * styles: [
595
596
  * {
596
- * key: 'color', // Match `color` as an property key.
597
- * 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
+ * },
598
600
  * {
599
601
  * key: /^border.*$/, // Match any border style.
600
602
  * value: true // Match any value.
@@ -647,6 +649,7 @@ function matchStyles( patterns, element ) {
647
649
  * {
648
650
  * key: 'image', // Match `image` class.
649
651
  * value: true
652
+ * },
650
653
  * {
651
654
  * key: /^image-side-(left|right)$/, // Match `image-side-left` or `image-side-right` class.
652
655
  * value: true
@@ -700,11 +703,9 @@ function matchStyles( patterns, element ) {
700
703
  * @typedef {String|RegExp|Object|Function} module:engine/view/matcher~MatcherPattern
701
704
  *
702
705
  * @property {String|RegExp} [name] View element name to match.
703
- * @property {String|RegExp|Array.<String|RegExp>} [classes] View element's class name(s) to match.
704
- * @property {Object} [styles] Object with key-value pairs representing styles to match.
705
- * Each object key represents style name. Value can be given as `String` or `RegExp`.
706
- * @property {Object} [attributes] Object with key-value pairs representing attributes to match.
707
- * 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.
708
709
  */
709
710
 
710
711
  /**
@@ -81,7 +81,7 @@ export default class SelectionObserver extends Observer {
81
81
  this._fireSelectionChangeDoneDebounced = debounce( data => this.document.fire( 'selectionChangeDone', data ), 200 );
82
82
 
83
83
  /**
84
- * When called, starts clearing the {@link #_loopbackCounter} counter in intervals of time. When the number of selection
84
+ * When called, starts clearing the {@link #_loopbackCounter} counter in time intervals. When the number of selection
85
85
  * changes exceeds a certain limit within the interval of time, the observer will not fire `selectionChange` but warn about
86
86
  * possible infinite selection loop.
87
87
  *
@@ -92,7 +92,7 @@ export default class SelectionObserver extends Observer {
92
92
 
93
93
  /**
94
94
  * Unlocks the `isSelecting` state of the view document in case the selection observer did not record this fact
95
- * correctly (for whatever the reason). It is a safeguard (paranoid check) that returns document to the normal state
95
+ * correctly (for whatever reason). It is a safeguard (paranoid check), that returns document to the normal state
96
96
  * after a certain period of time (debounced, postponed by each selectionchange event).
97
97
  *
98
98
  * @private
@@ -120,7 +120,7 @@ export default class Renderer {
120
120
  // Rendering the selection and inline filler manipulation should be postponed in (non-Android) Blink until the user finishes
121
121
  // creating the selection in DOM to avoid accidental selection collapsing
122
122
  // (https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723).
123
- // When the user stops, selecting, all pending changes should be rendered ASAP, though.
123
+ // When the user stops selecting, all pending changes should be rendered ASAP, though.
124
124
  if ( env.isBlink && !env.isAndroid ) {
125
125
  this.on( 'change:isSelecting', () => {
126
126
  if ( !this.isSelecting ) {
@@ -555,25 +555,14 @@ export default class Renderer {
555
555
 
556
556
  // Add or overwrite attributes.
557
557
  for ( const key of viewAttrKeys ) {
558
- const value = viewElement.getAttribute( key );
559
-
560
- if ( !this.domConverter.shouldRenderAttribute( key, value ) ) {
561
- domElement.removeAttribute( key );
562
- } else {
563
- domElement.setAttribute( key, value );
564
- }
558
+ this.domConverter.setDomElementAttribute( domElement, key, viewElement.getAttribute( key ), viewElement );
565
559
  }
566
560
 
567
561
  // Remove from DOM attributes which do not exists in the view.
568
562
  for ( const key of domAttrKeys ) {
569
- // Do not remove attributes on `script` elements with special data attributes `data-ck-hidden`.
570
- if ( viewElement.name === 'script' && key === 'data-ck-hidden' ) {
571
- continue;
572
- }
573
-
574
563
  // All other attributes not present in the DOM should be removed.
575
564
  if ( !viewElement.hasAttribute( key ) ) {
576
- domElement.removeAttribute( key );
565
+ this.domConverter.removeDomElementAttribute( domElement, key );
577
566
  }
578
567
  }
579
568
  }
@@ -742,7 +731,7 @@ export default class Renderer {
742
731
  _updateSelection() {
743
732
  // Block updating DOM selection in (non-Android) Blink while the user is selecting to prevent accidental selection collapsing.
744
733
  // Note: Structural changes in DOM must trigger selection rendering, though. Nodes the selection was anchored
745
- // to may disappear in DOM which would break the selection (e.g. in real-time collaboration scenarios).
734
+ // to, may disappear in DOM which would break the selection (e.g. in real-time collaboration scenarios).
746
735
  // https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723
747
736
  if ( env.isBlink && !env.isAndroid && this.isSelecting && !this.markedChildren.size ) {
748
737
  return;
@@ -4,6 +4,6 @@
4
4
  */
5
5
 
6
6
  /* Elements marked by the Renderer as hidden should be invisible in the editor. */
7
- .ck.ck-editor__editable span[data-ck-hidden] {
7
+ .ck.ck-editor__editable span[data-ck-unsafe-element] {
8
8
  display: none;
9
9
  }