@ckeditor/ckeditor5-engine 34.2.0 → 35.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +823 -0
  2. package/LICENSE.md +4 -0
  3. package/package.json +32 -25
  4. package/src/controller/datacontroller.js +467 -561
  5. package/src/controller/editingcontroller.js +168 -204
  6. package/src/conversion/conversion.js +541 -565
  7. package/src/conversion/conversionhelpers.js +24 -28
  8. package/src/conversion/downcastdispatcher.js +457 -686
  9. package/src/conversion/downcasthelpers.js +1583 -1965
  10. package/src/conversion/mapper.js +518 -707
  11. package/src/conversion/modelconsumable.js +240 -283
  12. package/src/conversion/upcastdispatcher.js +372 -718
  13. package/src/conversion/upcasthelpers.js +707 -818
  14. package/src/conversion/viewconsumable.js +524 -581
  15. package/src/dataprocessor/basichtmlwriter.js +12 -16
  16. package/src/dataprocessor/dataprocessor.js +5 -0
  17. package/src/dataprocessor/htmldataprocessor.js +101 -117
  18. package/src/dataprocessor/htmlwriter.js +1 -18
  19. package/src/dataprocessor/xmldataprocessor.js +117 -138
  20. package/src/dev-utils/model.js +260 -352
  21. package/src/dev-utils/operationreplayer.js +106 -126
  22. package/src/dev-utils/utils.js +34 -51
  23. package/src/dev-utils/view.js +632 -753
  24. package/src/index.js +0 -11
  25. package/src/model/batch.js +111 -127
  26. package/src/model/differ.js +988 -1233
  27. package/src/model/document.js +340 -449
  28. package/src/model/documentfragment.js +327 -364
  29. package/src/model/documentselection.js +996 -1189
  30. package/src/model/element.js +306 -410
  31. package/src/model/history.js +224 -262
  32. package/src/model/item.js +5 -0
  33. package/src/model/liveposition.js +84 -145
  34. package/src/model/liverange.js +108 -185
  35. package/src/model/markercollection.js +379 -480
  36. package/src/model/model.js +883 -1034
  37. package/src/model/node.js +419 -463
  38. package/src/model/nodelist.js +175 -201
  39. package/src/model/operation/attributeoperation.js +153 -182
  40. package/src/model/operation/detachoperation.js +64 -83
  41. package/src/model/operation/insertoperation.js +135 -166
  42. package/src/model/operation/markeroperation.js +114 -140
  43. package/src/model/operation/mergeoperation.js +163 -191
  44. package/src/model/operation/moveoperation.js +157 -187
  45. package/src/model/operation/nooperation.js +28 -38
  46. package/src/model/operation/operation.js +106 -125
  47. package/src/model/operation/operationfactory.js +30 -34
  48. package/src/model/operation/renameoperation.js +109 -135
  49. package/src/model/operation/rootattributeoperation.js +155 -188
  50. package/src/model/operation/splitoperation.js +196 -232
  51. package/src/model/operation/transform.js +1833 -2204
  52. package/src/model/operation/utils.js +140 -204
  53. package/src/model/position.js +899 -1053
  54. package/src/model/range.js +910 -1028
  55. package/src/model/rootelement.js +77 -97
  56. package/src/model/schema.js +1189 -1835
  57. package/src/model/selection.js +745 -862
  58. package/src/model/text.js +90 -114
  59. package/src/model/textproxy.js +204 -240
  60. package/src/model/treewalker.js +316 -397
  61. package/src/model/typecheckable.js +16 -0
  62. package/src/model/utils/autoparagraphing.js +32 -44
  63. package/src/model/utils/deletecontent.js +334 -418
  64. package/src/model/utils/findoptimalinsertionrange.js +25 -36
  65. package/src/model/utils/getselectedcontent.js +96 -118
  66. package/src/model/utils/insertcontent.js +654 -773
  67. package/src/model/utils/insertobject.js +96 -119
  68. package/src/model/utils/modifyselection.js +120 -158
  69. package/src/model/utils/selection-post-fixer.js +153 -201
  70. package/src/model/writer.js +1305 -1474
  71. package/src/view/attributeelement.js +189 -225
  72. package/src/view/containerelement.js +75 -85
  73. package/src/view/document.js +172 -215
  74. package/src/view/documentfragment.js +200 -249
  75. package/src/view/documentselection.js +338 -367
  76. package/src/view/domconverter.js +1371 -1613
  77. package/src/view/downcastwriter.js +1747 -2076
  78. package/src/view/editableelement.js +81 -97
  79. package/src/view/element.js +739 -890
  80. package/src/view/elementdefinition.js +5 -0
  81. package/src/view/emptyelement.js +82 -92
  82. package/src/view/filler.js +35 -50
  83. package/src/view/item.js +5 -0
  84. package/src/view/matcher.js +260 -559
  85. package/src/view/node.js +274 -360
  86. package/src/view/observer/arrowkeysobserver.js +19 -28
  87. package/src/view/observer/bubblingemittermixin.js +120 -263
  88. package/src/view/observer/bubblingeventinfo.js +47 -55
  89. package/src/view/observer/clickobserver.js +7 -13
  90. package/src/view/observer/compositionobserver.js +14 -24
  91. package/src/view/observer/domeventdata.js +57 -67
  92. package/src/view/observer/domeventobserver.js +40 -64
  93. package/src/view/observer/fakeselectionobserver.js +81 -96
  94. package/src/view/observer/focusobserver.js +45 -61
  95. package/src/view/observer/inputobserver.js +7 -13
  96. package/src/view/observer/keyobserver.js +17 -27
  97. package/src/view/observer/mouseobserver.js +7 -14
  98. package/src/view/observer/mutationobserver.js +220 -315
  99. package/src/view/observer/observer.js +81 -102
  100. package/src/view/observer/selectionobserver.js +191 -246
  101. package/src/view/observer/tabobserver.js +23 -36
  102. package/src/view/placeholder.js +128 -173
  103. package/src/view/position.js +350 -401
  104. package/src/view/range.js +453 -513
  105. package/src/view/rawelement.js +85 -112
  106. package/src/view/renderer.js +874 -1014
  107. package/src/view/rooteditableelement.js +80 -90
  108. package/src/view/selection.js +608 -689
  109. package/src/view/styles/background.js +43 -44
  110. package/src/view/styles/border.js +220 -276
  111. package/src/view/styles/margin.js +8 -17
  112. package/src/view/styles/padding.js +8 -16
  113. package/src/view/styles/utils.js +127 -160
  114. package/src/view/stylesmap.js +728 -905
  115. package/src/view/text.js +102 -126
  116. package/src/view/textproxy.js +144 -170
  117. package/src/view/treewalker.js +383 -479
  118. package/src/view/typecheckable.js +19 -0
  119. package/src/view/uielement.js +166 -187
  120. package/src/view/upcastwriter.js +395 -449
  121. package/src/view/view.js +569 -664
  122. package/src/dataprocessor/dataprocessor.jsdoc +0 -64
  123. package/src/model/item.jsdoc +0 -14
  124. package/src/view/elementdefinition.jsdoc +0 -59
  125. package/src/view/item.jsdoc +0 -14
@@ -2,17 +2,13 @@
2
2
  * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
3
3
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4
4
  */
5
-
6
5
  /**
7
6
  * @module engine/dev-utils/view
8
7
  */
9
-
10
8
  /* globals document */
11
-
12
9
  /**
13
10
  * Collection of methods for manipulating the {@link module:engine/view/view view} for testing purposes.
14
11
  */
15
-
16
12
  import View from '../view/view';
17
13
  import ViewDocument from '../view/document';
18
14
  import ViewDocumentFragment from '../view/documentfragment';
@@ -27,26 +23,24 @@ import EmptyElement from '../view/emptyelement';
27
23
  import UIElement from '../view/uielement';
28
24
  import RawElement from '../view/rawelement';
29
25
  import { StylesProcessor } from '../view/stylesmap';
30
-
31
26
  const ELEMENT_RANGE_START_TOKEN = '[';
32
27
  const ELEMENT_RANGE_END_TOKEN = ']';
33
28
  const TEXT_RANGE_START_TOKEN = '{';
34
29
  const TEXT_RANGE_END_TOKEN = '}';
35
30
  const allowedTypes = {
36
- 'container': ContainerElement,
37
- 'attribute': AttributeElement,
38
- 'empty': EmptyElement,
39
- 'ui': UIElement,
40
- 'raw': RawElement
31
+ 'container': ContainerElement,
32
+ 'attribute': AttributeElement,
33
+ 'empty': EmptyElement,
34
+ 'ui': UIElement,
35
+ 'raw': RawElement
41
36
  };
42
37
  // Returns simplified implementation of {@link module:engine/view/domconverter~DomConverter#setContentOf DomConverter.setContentOf} method.
43
38
  // Used to render UIElement and RawElement.
44
39
  const domConverterStub = {
45
- setContentOf: ( node, html ) => {
46
- node.innerHTML = html;
47
- }
40
+ setContentOf: (node, html) => {
41
+ node.innerHTML = html;
42
+ }
48
43
  };
49
-
50
44
  /**
51
45
  * Writes the content of the {@link module:engine/view/document~Document document} to an HTML-like string.
52
46
  *
@@ -71,32 +65,28 @@ const domConverterStub = {
71
65
  * i.e. with view data filtering. Otherwise the simple stub is used.
72
66
  * @returns {String} The stringified data.
73
67
  */
74
- export function getData( view, options = {} ) {
75
- if ( !( view instanceof View ) ) {
76
- throw new TypeError( 'View needs to be an instance of module:engine/view/view~View.' );
77
- }
78
-
79
- const document = view.document;
80
- const withoutSelection = !!options.withoutSelection;
81
- const rootName = options.rootName || 'main';
82
- const root = document.getRoot( rootName );
83
- const stringifyOptions = {
84
- showType: options.showType,
85
- showPriority: options.showPriority,
86
- renderUIElements: options.renderUIElements,
87
- renderRawElements: options.renderRawElements,
88
- ignoreRoot: true,
89
- domConverter: options.domConverter
90
- };
91
-
92
- return withoutSelection ?
93
- getData._stringify( root, null, stringifyOptions ) :
94
- getData._stringify( root, document.selection, stringifyOptions );
68
+ export function getData(view, options = {}) {
69
+ if (!(view instanceof View)) {
70
+ throw new TypeError('View needs to be an instance of module:engine/view/view~View.');
71
+ }
72
+ const document = view.document;
73
+ const withoutSelection = !!options.withoutSelection;
74
+ const rootName = options.rootName || 'main';
75
+ const root = document.getRoot(rootName);
76
+ const stringifyOptions = {
77
+ showType: options.showType,
78
+ showPriority: options.showPriority,
79
+ renderUIElements: options.renderUIElements,
80
+ renderRawElements: options.renderRawElements,
81
+ ignoreRoot: true,
82
+ domConverter: options.domConverter
83
+ };
84
+ return withoutSelection ?
85
+ getData._stringify(root, null, stringifyOptions) :
86
+ getData._stringify(root, document.selection, stringifyOptions);
95
87
  }
96
-
97
88
  // Set stringify as getData private method - needed for testing/spying.
98
89
  getData._stringify = stringify;
99
-
100
90
  /**
101
91
  * Sets the content of a view {@link module:engine/view/document~Document document} provided as an HTML-like string.
102
92
  *
@@ -106,27 +96,22 @@ getData._stringify = stringify;
106
96
  * @param {String} [options.rootName='main'] The root name where parsed data will be stored. If not provided,
107
97
  * the default `main` name will be used.
108
98
  */
109
- export function setData( view, data, options = {} ) {
110
- if ( !( view instanceof View ) ) {
111
- throw new TypeError( 'View needs to be an instance of module:engine/view/view~View.' );
112
- }
113
-
114
- const document = view.document;
115
- const rootName = options.rootName || 'main';
116
- const root = document.getRoot( rootName );
117
-
118
- view.change( writer => {
119
- const result = setData._parse( data, { rootElement: root } );
120
-
121
- if ( result.view && result.selection ) {
122
- writer.setSelection( result.selection );
123
- }
124
- } );
99
+ export function setData(view, data, options = {}) {
100
+ if (!(view instanceof View)) {
101
+ throw new TypeError('View needs to be an instance of module:engine/view/view~View.');
102
+ }
103
+ const document = view.document;
104
+ const rootName = options.rootName || 'main';
105
+ const root = document.getRoot(rootName);
106
+ view.change(writer => {
107
+ const result = setData._parse(data, { rootElement: root });
108
+ if (result.view && result.selection) {
109
+ writer.setSelection(result.selection);
110
+ }
111
+ });
125
112
  }
126
-
127
113
  // Set parse as setData private method - needed for testing/spying.
128
114
  setData._parse = parse;
129
-
130
115
  /**
131
116
  * Converts view elements to HTML-like string representation.
132
117
  *
@@ -257,23 +242,18 @@ setData._parse = parse;
257
242
  * i.e. with view data filtering. Otherwise the simple stub is used.
258
243
  * @returns {String} An HTML-like string representing the view.
259
244
  */
260
- export function stringify( node, selectionOrPositionOrRange = null, options = {} ) {
261
- let selection;
262
-
263
- if (
264
- selectionOrPositionOrRange instanceof Position ||
265
- selectionOrPositionOrRange instanceof Range
266
- ) {
267
- selection = new DocumentSelection( selectionOrPositionOrRange );
268
- } else {
269
- selection = selectionOrPositionOrRange;
270
- }
271
-
272
- const viewStringify = new ViewStringify( node, selection, options );
273
-
274
- return viewStringify.stringify();
245
+ export function stringify(node, selectionOrPositionOrRange = null, options = {}) {
246
+ let selection;
247
+ if (selectionOrPositionOrRange instanceof Position ||
248
+ selectionOrPositionOrRange instanceof Range) {
249
+ selection = new DocumentSelection(selectionOrPositionOrRange);
250
+ }
251
+ else {
252
+ selection = selectionOrPositionOrRange;
253
+ }
254
+ const viewStringify = new ViewStringify(node, selection, options);
255
+ return viewStringify.stringify();
275
256
  }
276
-
277
257
  /**
278
258
  * Parses an HTML-like string and returns a view tree.
279
259
  * A simple string will be converted to a {@link module:engine/view/text~Text text} node:
@@ -349,610 +329,528 @@ export function stringify( node, selectionOrPositionOrRange = null, options = {}
349
329
  * Returns the parsed view node or an object with two fields: `view` and `selection` when selection ranges were included in the data
350
330
  * to parse.
351
331
  */
352
- export function parse( data, options = {} ) {
353
- const viewDocument = new ViewDocument( new StylesProcessor() );
354
-
355
- options.order = options.order || [];
356
- const rangeParser = new RangeParser( {
357
- sameSelectionCharacters: options.sameSelectionCharacters
358
- } );
359
- const processor = new XmlDataProcessor( viewDocument, {
360
- namespaces: Object.keys( allowedTypes )
361
- } );
362
-
363
- // Convert data to view.
364
- let view = processor.toView( data );
365
-
366
- // At this point we have a view tree with Elements that could have names like `attribute:b:1`. In the next step
367
- // we need to parse Element's names and convert them to AttributeElements and ContainerElements.
368
- view = _convertViewElements( view );
369
-
370
- // If custom root is provided - move all nodes there.
371
- if ( options.rootElement ) {
372
- const root = options.rootElement;
373
- const nodes = view._removeChildren( 0, view.childCount );
374
-
375
- root._removeChildren( 0, root.childCount );
376
- root._appendChild( nodes );
377
-
378
- view = root;
379
- }
380
-
381
- // Parse ranges included in view text nodes.
382
- const ranges = rangeParser.parse( view, options.order );
383
-
384
- // If only one element is returned inside DocumentFragment - return that element.
385
- if ( view.is( 'documentFragment' ) && view.childCount === 1 ) {
386
- view = view.getChild( 0 );
387
- }
388
-
389
- // When ranges are present - return object containing view, and selection.
390
- if ( ranges.length ) {
391
- const selection = new DocumentSelection( ranges, { backward: !!options.lastRangeBackward } );
392
-
393
- return {
394
- view,
395
- selection
396
- };
397
- }
398
-
399
- // If single element is returned without selection - remove it from parent and return detached element.
400
- if ( view.parent ) {
401
- view._remove();
402
- }
403
-
404
- return view;
332
+ export function parse(data, options = {}) {
333
+ const viewDocument = new ViewDocument(new StylesProcessor());
334
+ options.order = options.order || [];
335
+ const rangeParser = new RangeParser({
336
+ sameSelectionCharacters: options.sameSelectionCharacters
337
+ });
338
+ const processor = new XmlDataProcessor(viewDocument, {
339
+ namespaces: Object.keys(allowedTypes)
340
+ });
341
+ // Convert data to view.
342
+ let view = processor.toView(data);
343
+ // At this point we have a view tree with Elements that could have names like `attribute:b:1`. In the next step
344
+ // we need to parse Element's names and convert them to AttributeElements and ContainerElements.
345
+ view = _convertViewElements(view);
346
+ // If custom root is provided - move all nodes there.
347
+ if (options.rootElement) {
348
+ const root = options.rootElement;
349
+ const nodes = view._removeChildren(0, view.childCount);
350
+ root._removeChildren(0, root.childCount);
351
+ root._appendChild(nodes);
352
+ view = root;
353
+ }
354
+ // Parse ranges included in view text nodes.
355
+ const ranges = rangeParser.parse(view, options.order);
356
+ // If only one element is returned inside DocumentFragment - return that element.
357
+ if (view.is('documentFragment') && view.childCount === 1) {
358
+ view = view.getChild(0);
359
+ }
360
+ // When ranges are present - return object containing view, and selection.
361
+ if (ranges.length) {
362
+ const selection = new DocumentSelection(ranges, { backward: !!options.lastRangeBackward });
363
+ return {
364
+ view,
365
+ selection
366
+ };
367
+ }
368
+ // If single element is returned without selection - remove it from parent and return detached element.
369
+ if (view.parent) {
370
+ view._remove();
371
+ }
372
+ return view;
405
373
  }
406
-
407
374
  /**
408
375
  * Private helper class used for converting ranges represented as text inside view {@link module:engine/view/text~Text text nodes}.
409
376
  *
410
377
  * @private
411
378
  */
412
379
  class RangeParser {
413
- /**
414
- * Creates a range parser instance.
415
- *
416
- * @param {Object} options The range parser configuration.
417
- * @param {Boolean} [options.sameSelectionCharacters=false] When set to `true`, the selection inside the text is marked as
418
- * `{` and `}` and the selection outside the text as `[` and `]`. When set to `false`, both are marked as `[` and `]`.
419
- */
420
- constructor( options ) {
421
- this.sameSelectionCharacters = !!options.sameSelectionCharacters;
422
- }
423
-
424
- /**
425
- * Parses the view and returns ranges represented inside {@link module:engine/view/text~Text text nodes}.
426
- * The method will remove all occurrences of `{`, `}`, `[` and `]` from found text nodes. If a text node is empty after
427
- * the process, it will be removed, too.
428
- *
429
- * @param {module:engine/view/node~Node} node The starting node.
430
- * @param {Array.<Number>} order The order of ranges. Each element should represent the desired position of the range after
431
- * sorting. For example: `[2, 3, 1]` means that the first range will be placed as the second, the second as the third and the third
432
- * as the first.
433
- * @returns {Array.<module:engine/view/range~Range>} An array with ranges found.
434
- */
435
- parse( node, order ) {
436
- this._positions = [];
437
-
438
- // Remove all range brackets from view nodes and save their positions.
439
- this._getPositions( node );
440
-
441
- // Create ranges using gathered positions.
442
- let ranges = this._createRanges();
443
-
444
- // Sort ranges if needed.
445
- if ( order.length ) {
446
- if ( order.length != ranges.length ) {
447
- throw new Error(
448
- `Parse error - there are ${ ranges.length } ranges found, but ranges order array contains ${ order.length } elements.`
449
- );
450
- }
451
-
452
- ranges = this._sortRanges( ranges, order );
453
- }
454
-
455
- return ranges;
456
- }
457
-
458
- /**
459
- * Gathers positions of brackets inside the view tree starting from the provided node. The method will remove all occurrences of
460
- * `{`, `}`, `[` and `]` from found text nodes. If a text node is empty after the process, it will be removed, too.
461
- *
462
- * @private
463
- * @param {module:engine/view/node~Node} node Staring node.
464
- */
465
- _getPositions( node ) {
466
- if ( node.is( 'documentFragment' ) || node.is( 'element' ) ) {
467
- // Copy elements into the array, when nodes will be removed from parent node this array will still have all the
468
- // items needed for iteration.
469
- const children = [ ...node.getChildren() ];
470
-
471
- for ( const child of children ) {
472
- this._getPositions( child );
473
- }
474
- }
475
-
476
- if ( node.is( '$text' ) ) {
477
- const regexp = new RegExp(
478
- `[${ TEXT_RANGE_START_TOKEN }${ TEXT_RANGE_END_TOKEN }\\${ ELEMENT_RANGE_END_TOKEN }\\${ ELEMENT_RANGE_START_TOKEN }]`,
479
- 'g'
480
- );
481
- let text = node.data;
482
- let match;
483
- let offset = 0;
484
- const brackets = [];
485
-
486
- // Remove brackets from text and store info about offset inside text node.
487
- while ( ( match = regexp.exec( text ) ) ) {
488
- const index = match.index;
489
- const bracket = match[ 0 ];
490
-
491
- brackets.push( {
492
- bracket,
493
- textOffset: index - offset
494
- } );
495
-
496
- offset++;
497
- }
498
-
499
- text = text.replace( regexp, '' );
500
- node._data = text;
501
- const index = node.index;
502
- const parent = node.parent;
503
-
504
- // Remove empty text nodes.
505
- if ( !text ) {
506
- node._remove();
507
- }
508
-
509
- for ( const item of brackets ) {
510
- // Non-empty text node.
511
- if ( text ) {
512
- if (
513
- this.sameSelectionCharacters ||
514
- (
515
- !this.sameSelectionCharacters &&
516
- ( item.bracket == TEXT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN )
517
- )
518
- ) {
519
- // Store information about text range delimiter.
520
- this._positions.push( {
521
- bracket: item.bracket,
522
- position: new Position( node, item.textOffset )
523
- } );
524
- } else {
525
- // Check if element range delimiter is not placed inside text node.
526
- if ( !this.sameSelectionCharacters && item.textOffset !== 0 && item.textOffset !== text.length ) {
527
- throw new Error( `Parse error - range delimiter '${ item.bracket }' is placed inside text node.` );
528
- }
529
-
530
- // If bracket is placed at the end of the text node - it should be positioned after it.
531
- const offset = ( item.textOffset === 0 ? index : index + 1 );
532
-
533
- // Store information about element range delimiter.
534
- this._positions.push( {
535
- bracket: item.bracket,
536
- position: new Position( parent, offset )
537
- } );
538
- }
539
- } else {
540
- if ( !this.sameSelectionCharacters &&
541
- item.bracket == TEXT_RANGE_START_TOKEN ||
542
- item.bracket == TEXT_RANGE_END_TOKEN
543
- ) {
544
- throw new Error( `Parse error - text range delimiter '${ item.bracket }' is placed inside empty text node. ` );
545
- }
546
-
547
- // Store information about element range delimiter.
548
- this._positions.push( {
549
- bracket: item.bracket,
550
- position: new Position( parent, index )
551
- } );
552
- }
553
- }
554
- }
555
- }
556
-
557
- /**
558
- * Sorts ranges in a given order. Range order should be an array and each element should represent the desired position
559
- * of the range after sorting.
560
- * For example: `[2, 3, 1]` means that the first range will be placed as the second, the second as the third and the third
561
- * as the first.
562
- *
563
- * @private
564
- * @param {Array.<module:engine/view/range~Range>} ranges Ranges to sort.
565
- * @param {Array.<Number>} rangesOrder An array with new range order.
566
- * @returns {Array} Sorted ranges array.
567
- */
568
- _sortRanges( ranges, rangesOrder ) {
569
- const sortedRanges = [];
570
- let index = 0;
571
-
572
- for ( const newPosition of rangesOrder ) {
573
- if ( ranges[ newPosition - 1 ] === undefined ) {
574
- throw new Error( 'Parse error - provided ranges order is invalid.' );
575
- }
576
-
577
- sortedRanges[ newPosition - 1 ] = ranges[ index ];
578
- index++;
579
- }
580
-
581
- return sortedRanges;
582
- }
583
-
584
- /**
585
- * Uses all found bracket positions to create ranges from them.
586
- *
587
- * @private
588
- * @returns {Array.<module:engine/view/range~Range>}
589
- */
590
- _createRanges() {
591
- const ranges = [];
592
- let range = null;
593
-
594
- for ( const item of this._positions ) {
595
- // When end of range is found without opening.
596
- if ( !range && ( item.bracket == ELEMENT_RANGE_END_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN ) ) {
597
- throw new Error( `Parse error - end of range was found '${ item.bracket }' but range was not started before.` );
598
- }
599
-
600
- // When second start of range is found when one is already opened - selection does not allow intersecting
601
- // ranges.
602
- if ( range && ( item.bracket == ELEMENT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_START_TOKEN ) ) {
603
- throw new Error( `Parse error - start of range was found '${ item.bracket }' but one range is already started.` );
604
- }
605
-
606
- if ( item.bracket == ELEMENT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_START_TOKEN ) {
607
- range = new Range( item.position, item.position );
608
- } else {
609
- range.end = item.position;
610
- ranges.push( range );
611
- range = null;
612
- }
613
- }
614
-
615
- // Check if all ranges have proper ending.
616
- if ( range !== null ) {
617
- throw new Error( 'Parse error - range was started but no end delimiter was found.' );
618
- }
619
-
620
- return ranges;
621
- }
380
+ /**
381
+ * Creates a range parser instance.
382
+ *
383
+ * @param {Object} options The range parser configuration.
384
+ * @param {Boolean} [options.sameSelectionCharacters=false] When set to `true`, the selection inside the text is marked as
385
+ * `{` and `}` and the selection outside the text as `[` and `]`. When set to `false`, both are marked as `[` and `]`.
386
+ */
387
+ constructor(options) {
388
+ this.sameSelectionCharacters = !!options.sameSelectionCharacters;
389
+ }
390
+ /**
391
+ * Parses the view and returns ranges represented inside {@link module:engine/view/text~Text text nodes}.
392
+ * The method will remove all occurrences of `{`, `}`, `[` and `]` from found text nodes. If a text node is empty after
393
+ * the process, it will be removed, too.
394
+ *
395
+ * @param {module:engine/view/node~Node} node The starting node.
396
+ * @param {Array.<Number>} order The order of ranges. Each element should represent the desired position of the range after
397
+ * sorting. For example: `[2, 3, 1]` means that the first range will be placed as the second, the second as the third and the third
398
+ * as the first.
399
+ * @returns {Array.<module:engine/view/range~Range>} An array with ranges found.
400
+ */
401
+ parse(node, order) {
402
+ this._positions = [];
403
+ // Remove all range brackets from view nodes and save their positions.
404
+ this._getPositions(node);
405
+ // Create ranges using gathered positions.
406
+ let ranges = this._createRanges();
407
+ // Sort ranges if needed.
408
+ if (order.length) {
409
+ if (order.length != ranges.length) {
410
+ throw new Error(`Parse error - there are ${ranges.length} ranges found, but ranges order array contains ${order.length} elements.`);
411
+ }
412
+ ranges = this._sortRanges(ranges, order);
413
+ }
414
+ return ranges;
415
+ }
416
+ /**
417
+ * Gathers positions of brackets inside the view tree starting from the provided node. The method will remove all occurrences of
418
+ * `{`, `}`, `[` and `]` from found text nodes. If a text node is empty after the process, it will be removed, too.
419
+ *
420
+ * @private
421
+ * @param {module:engine/view/node~Node} node Staring node.
422
+ */
423
+ _getPositions(node) {
424
+ if (node.is('documentFragment') || node.is('element')) {
425
+ // Copy elements into the array, when nodes will be removed from parent node this array will still have all the
426
+ // items needed for iteration.
427
+ const children = [...node.getChildren()];
428
+ for (const child of children) {
429
+ this._getPositions(child);
430
+ }
431
+ }
432
+ if (node.is('$text')) {
433
+ const regexp = new RegExp(`[${TEXT_RANGE_START_TOKEN}${TEXT_RANGE_END_TOKEN}\\${ELEMENT_RANGE_END_TOKEN}\\${ELEMENT_RANGE_START_TOKEN}]`, 'g');
434
+ let text = node.data;
435
+ let match;
436
+ let offset = 0;
437
+ const brackets = [];
438
+ // Remove brackets from text and store info about offset inside text node.
439
+ while ((match = regexp.exec(text))) {
440
+ const index = match.index;
441
+ const bracket = match[0];
442
+ brackets.push({
443
+ bracket,
444
+ textOffset: index - offset
445
+ });
446
+ offset++;
447
+ }
448
+ text = text.replace(regexp, '');
449
+ node._data = text;
450
+ const index = node.index;
451
+ const parent = node.parent;
452
+ // Remove empty text nodes.
453
+ if (!text) {
454
+ node._remove();
455
+ }
456
+ for (const item of brackets) {
457
+ // Non-empty text node.
458
+ if (text) {
459
+ if (this.sameSelectionCharacters ||
460
+ (!this.sameSelectionCharacters &&
461
+ (item.bracket == TEXT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN))) {
462
+ // Store information about text range delimiter.
463
+ this._positions.push({
464
+ bracket: item.bracket,
465
+ position: new Position(node, item.textOffset)
466
+ });
467
+ }
468
+ else {
469
+ // Check if element range delimiter is not placed inside text node.
470
+ if (!this.sameSelectionCharacters && item.textOffset !== 0 && item.textOffset !== text.length) {
471
+ throw new Error(`Parse error - range delimiter '${item.bracket}' is placed inside text node.`);
472
+ }
473
+ // If bracket is placed at the end of the text node - it should be positioned after it.
474
+ const offset = (item.textOffset === 0 ? index : index + 1);
475
+ // Store information about element range delimiter.
476
+ this._positions.push({
477
+ bracket: item.bracket,
478
+ position: new Position(parent, offset)
479
+ });
480
+ }
481
+ }
482
+ else {
483
+ if (!this.sameSelectionCharacters &&
484
+ item.bracket == TEXT_RANGE_START_TOKEN ||
485
+ item.bracket == TEXT_RANGE_END_TOKEN) {
486
+ throw new Error(`Parse error - text range delimiter '${item.bracket}' is placed inside empty text node. `);
487
+ }
488
+ // Store information about element range delimiter.
489
+ this._positions.push({
490
+ bracket: item.bracket,
491
+ position: new Position(parent, index)
492
+ });
493
+ }
494
+ }
495
+ }
496
+ }
497
+ /**
498
+ * Sorts ranges in a given order. Range order should be an array and each element should represent the desired position
499
+ * of the range after sorting.
500
+ * For example: `[2, 3, 1]` means that the first range will be placed as the second, the second as the third and the third
501
+ * as the first.
502
+ *
503
+ * @private
504
+ * @param {Array.<module:engine/view/range~Range>} ranges Ranges to sort.
505
+ * @param {Array.<Number>} rangesOrder An array with new range order.
506
+ * @returns {Array} Sorted ranges array.
507
+ */
508
+ _sortRanges(ranges, rangesOrder) {
509
+ const sortedRanges = [];
510
+ let index = 0;
511
+ for (const newPosition of rangesOrder) {
512
+ if (ranges[newPosition - 1] === undefined) {
513
+ throw new Error('Parse error - provided ranges order is invalid.');
514
+ }
515
+ sortedRanges[newPosition - 1] = ranges[index];
516
+ index++;
517
+ }
518
+ return sortedRanges;
519
+ }
520
+ /**
521
+ * Uses all found bracket positions to create ranges from them.
522
+ *
523
+ * @private
524
+ * @returns {Array.<module:engine/view/range~Range>}
525
+ */
526
+ _createRanges() {
527
+ const ranges = [];
528
+ let range = null;
529
+ for (const item of this._positions) {
530
+ // When end of range is found without opening.
531
+ if (!range && (item.bracket == ELEMENT_RANGE_END_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN)) {
532
+ throw new Error(`Parse error - end of range was found '${item.bracket}' but range was not started before.`);
533
+ }
534
+ // When second start of range is found when one is already opened - selection does not allow intersecting
535
+ // ranges.
536
+ if (range && (item.bracket == ELEMENT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_START_TOKEN)) {
537
+ throw new Error(`Parse error - start of range was found '${item.bracket}' but one range is already started.`);
538
+ }
539
+ if (item.bracket == ELEMENT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_START_TOKEN) {
540
+ range = new Range(item.position, item.position);
541
+ }
542
+ else {
543
+ range.end = item.position;
544
+ ranges.push(range);
545
+ range = null;
546
+ }
547
+ }
548
+ // Check if all ranges have proper ending.
549
+ if (range !== null) {
550
+ throw new Error('Parse error - range was started but no end delimiter was found.');
551
+ }
552
+ return ranges;
553
+ }
622
554
  }
623
-
624
555
  /**
625
556
  * Private helper class used for converting the view tree to a string.
626
557
  *
627
558
  * @private
628
559
  */
629
560
  class ViewStringify {
630
- /**
631
- * Creates a view stringify instance.
632
- *
633
- * @param root
634
- * @param {module:engine/view/documentselection~DocumentSelection} selection A selection whose ranges
635
- * should also be converted to a string.
636
- * @param {Object} options An options object.
637
- * @param {Boolean} [options.showType=false] When set to `true`, the type of elements will be printed (`<container:p>`
638
- * instead of `<p>`, `<attribute:b>` instead of `<b>` and `<empty:img>` instead of `<img>`).
639
- * @param {Boolean} [options.showPriority=false] When set to `true`, the attribute element's priority will be printed.
640
- * @param {Boolean} [options.ignoreRoot=false] When set to `true`, the root's element opening and closing tag will not
641
- * be outputted.
642
- * @param {Boolean} [options.sameSelectionCharacters=false] When set to `true`, the selection inside the text is marked as
643
- * `{` and `}` and the selection outside the text as `[` and `]`. When set to `false`, both are marked as `[` and `]`.
644
- * @param {Boolean} [options.renderUIElements=false] When set to `true`, the inner content of each
645
- * {@link module:engine/view/uielement~UIElement} will be printed.
646
- * @param {Boolean} [options.renderRawElements=false] When set to `true`, the inner content of each
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 through,
649
- * i.e. with view data filtering. Otherwise the simple stub is used.
650
- * {@link module:engine/view/rawelement~RawElement} will be printed.
651
- */
652
- constructor( root, selection, options ) {
653
- this.root = root;
654
- this.selection = selection;
655
- this.ranges = [];
656
-
657
- if ( this.selection ) {
658
- this.ranges = [ ...selection.getRanges() ];
659
- }
660
-
661
- this.showType = !!options.showType;
662
- this.showPriority = !!options.showPriority;
663
- this.showAttributeElementId = !!options.showAttributeElementId;
664
- this.ignoreRoot = !!options.ignoreRoot;
665
- this.sameSelectionCharacters = !!options.sameSelectionCharacters;
666
- this.renderUIElements = !!options.renderUIElements;
667
- this.renderRawElements = !!options.renderRawElements;
668
- this.domConverter = options.domConverter || domConverterStub;
669
- }
670
-
671
- /**
672
- * Converts the view to a string.
673
- *
674
- * @returns {String} String representation of the view elements.
675
- */
676
- stringify() {
677
- let result = '';
678
- this._walkView( this.root, chunk => {
679
- result += chunk;
680
- } );
681
-
682
- return result;
683
- }
684
-
685
- /**
686
- * Executes a simple walker that iterates over all elements in the view tree starting from the root element.
687
- * Calls the `callback` with parsed chunks of string data.
688
- *
689
- * @private
690
- * @param {module:engine/view/documentfragment~DocumentFragment|module:engine/view/element~Element|module:engine/view/text~Text} root
691
- * @param {Function} callback
692
- */
693
- _walkView( root, callback ) {
694
- const ignore = this.ignoreRoot && this.root === root;
695
-
696
- if ( root.is( 'element' ) || root.is( 'documentFragment' ) ) {
697
- if ( root.is( 'element' ) && !ignore ) {
698
- callback( this._stringifyElementOpen( root ) );
699
- }
700
-
701
- if ( ( this.renderUIElements && root.is( 'uiElement' ) ) ) {
702
- callback( root.render( document, this.domConverter ).innerHTML );
703
- } else if ( this.renderRawElements && root.is( 'rawElement' ) ) {
704
- // There's no DOM element for "root" to pass to render(). Creating
705
- // a surrogate container to render the children instead.
706
- const rawContentContainer = document.createElement( 'div' );
707
- root.render( rawContentContainer, this.domConverter );
708
-
709
- callback( rawContentContainer.innerHTML );
710
- } else {
711
- let offset = 0;
712
- callback( this._stringifyElementRanges( root, offset ) );
713
-
714
- for ( const child of root.getChildren() ) {
715
- this._walkView( child, callback );
716
- offset++;
717
- callback( this._stringifyElementRanges( root, offset ) );
718
- }
719
- }
720
-
721
- if ( root.is( 'element' ) && !ignore ) {
722
- callback( this._stringifyElementClose( root ) );
723
- }
724
- }
725
-
726
- if ( root.is( '$text' ) ) {
727
- callback( this._stringifyTextRanges( root ) );
728
- }
729
- }
730
-
731
- /**
732
- * Checks if a given {@link module:engine/view/element~Element element} has a {@link module:engine/view/range~Range#start range start}
733
- * or a {@link module:engine/view/range~Range#start range end} placed at a given offset and returns its string representation.
734
- *
735
- * @private
736
- * @param {module:engine/view/element~Element} element
737
- * @param {Number} offset
738
- */
739
- _stringifyElementRanges( element, offset ) {
740
- let start = '';
741
- let end = '';
742
- let collapsed = '';
743
-
744
- for ( const range of this.ranges ) {
745
- if ( range.start.parent == element && range.start.offset === offset ) {
746
- if ( range.isCollapsed ) {
747
- collapsed += ELEMENT_RANGE_START_TOKEN + ELEMENT_RANGE_END_TOKEN;
748
- } else {
749
- start += ELEMENT_RANGE_START_TOKEN;
750
- }
751
- }
752
-
753
- if ( range.end.parent === element && range.end.offset === offset && !range.isCollapsed ) {
754
- end += ELEMENT_RANGE_END_TOKEN;
755
- }
756
- }
757
-
758
- return end + collapsed + start;
759
- }
760
-
761
- /**
762
- * Checks if a given {@link module:engine/view/element~Element Text node} has a
763
- * {@link module:engine/view/range~Range#start range start} or a
764
- * {@link module:engine/view/range~Range#start range end} placed somewhere inside. Returns a string representation of text
765
- * with range delimiters placed inside.
766
- *
767
- * @private
768
- * @param {module:engine/view/text~Text} node
769
- */
770
- _stringifyTextRanges( node ) {
771
- const length = node.data.length;
772
- let result = node.data.split( '' );
773
- let rangeStartToken, rangeEndToken;
774
-
775
- if ( this.sameSelectionCharacters ) {
776
- rangeStartToken = ELEMENT_RANGE_START_TOKEN;
777
- rangeEndToken = ELEMENT_RANGE_END_TOKEN;
778
- } else {
779
- rangeStartToken = TEXT_RANGE_START_TOKEN;
780
- rangeEndToken = TEXT_RANGE_END_TOKEN;
781
- }
782
-
783
- // Add one more element for ranges ending after last character in text.
784
- result[ length ] = '';
785
-
786
- // Represent each letter as object with information about opening/closing ranges at each offset.
787
- result = result.map( letter => {
788
- return {
789
- letter,
790
- start: '',
791
- end: '',
792
- collapsed: ''
793
- };
794
- } );
795
-
796
- for ( const range of this.ranges ) {
797
- const start = range.start;
798
- const end = range.end;
799
-
800
- if ( start.parent == node && start.offset >= 0 && start.offset <= length ) {
801
- if ( range.isCollapsed ) {
802
- result[ end.offset ].collapsed += rangeStartToken + rangeEndToken;
803
- } else {
804
- result[ start.offset ].start += rangeStartToken;
805
- }
806
- }
807
-
808
- if ( end.parent == node && end.offset >= 0 && end.offset <= length && !range.isCollapsed ) {
809
- result[ end.offset ].end += rangeEndToken;
810
- }
811
- }
812
-
813
- return result.map( item => item.end + item.collapsed + item.start + item.letter ).join( '' );
814
- }
815
-
816
- /**
817
- * Converts the passed {@link module:engine/view/element~Element element} to an opening tag.
818
- *
819
- * Depending on the current configuration, the opening tag can be simple (`<a>`), contain a type prefix (`<container:p>`,
820
- * `<attribute:a>` or `<empty:img>`), contain priority information ( `<attribute:a view-priority="20">` ),
821
- * or contain element id ( `<attribute:span view-id="foo">` ). Element attributes will also be included
822
- * (`<a href="https://ckeditor.com" name="foobar">`).
823
- *
824
- * @private
825
- * @param {module:engine/view/element~Element} element
826
- * @returns {String}
827
- */
828
- _stringifyElementOpen( element ) {
829
- const priority = this._stringifyElementPriority( element );
830
- const id = this._stringifyElementId( element );
831
-
832
- const type = this._stringifyElementType( element );
833
- const name = [ type, element.name ].filter( i => i !== '' ).join( ':' );
834
- const attributes = this._stringifyElementAttributes( element );
835
- const parts = [ name, priority, id, attributes ];
836
-
837
- return `<${ parts.filter( i => i !== '' ).join( ' ' ) }>`;
838
- }
839
-
840
- /**
841
- * Converts the passed {@link module:engine/view/element~Element element} to a closing tag.
842
- * Depending on the current configuration, the closing tag can be simple (`</a>`) or contain a type prefix (`</container:p>`,
843
- * `</attribute:a>` or `</empty:img>`).
844
- *
845
- * @private
846
- * @param {module:engine/view/element~Element} element
847
- * @returns {String}
848
- */
849
- _stringifyElementClose( element ) {
850
- const type = this._stringifyElementType( element );
851
- const name = [ type, element.name ].filter( i => i !== '' ).join( ':' );
852
-
853
- return `</${ name }>`;
854
- }
855
-
856
- /**
857
- * Converts the passed {@link module:engine/view/element~Element element's} type to its string representation
858
- *
859
- * Returns:
860
- * * 'attribute' for {@link module:engine/view/attributeelement~AttributeElement attribute elements},
861
- * * 'container' for {@link module:engine/view/containerelement~ContainerElement container elements},
862
- * * 'empty' for {@link module:engine/view/emptyelement~EmptyElement empty elements},
863
- * * 'ui' for {@link module:engine/view/uielement~UIElement UI elements},
864
- * * 'raw' for {@link module:engine/view/rawelement~RawElement raw elements},
865
- * * an empty string when the current configuration is preventing showing elements' types.
866
- *
867
- * @private
868
- * @param {module:engine/view/element~Element} element
869
- * @returns {String}
870
- */
871
- _stringifyElementType( element ) {
872
- if ( this.showType ) {
873
- for ( const type in allowedTypes ) {
874
- if ( element instanceof allowedTypes[ type ] ) {
875
- return type;
876
- }
877
- }
878
- }
879
-
880
- return '';
881
- }
882
-
883
- /**
884
- * Converts the passed {@link module:engine/view/element~Element element} to its priority representation.
885
- *
886
- * The priority string representation will be returned when the passed element is an instance of
887
- * {@link module:engine/view/attributeelement~AttributeElement attribute element} and the current configuration allows to show the
888
- * priority. Otherwise returns an empty string.
889
- *
890
- * @private
891
- * @param {module:engine/view/element~Element} element
892
- * @returns {String}
893
- */
894
- _stringifyElementPriority( element ) {
895
- if ( this.showPriority && element.is( 'attributeElement' ) ) {
896
- return `view-priority="${ element.priority }"`;
897
- }
898
-
899
- return '';
900
- }
901
-
902
- /**
903
- * Converts the passed {@link module:engine/view/element~Element element} to its id representation.
904
- *
905
- * The id string representation will be returned when the passed element is an instance of
906
- * {@link module:engine/view/attributeelement~AttributeElement attribute element}, the element has an id
907
- * and the current configuration allows to show the id. Otherwise returns an empty string.
908
- *
909
- * @private
910
- * @param {module:engine/view/element~Element} element
911
- * @returns {String}
912
- */
913
- _stringifyElementId( element ) {
914
- if ( this.showAttributeElementId && element.is( 'attributeElement' ) && element.id ) {
915
- return `view-id="${ element.id }"`;
916
- }
917
-
918
- return '';
919
- }
920
-
921
- /**
922
- * Converts the passed {@link module:engine/view/element~Element element} attributes to their string representation.
923
- * If an element has no attributes, an empty string is returned.
924
- *
925
- * @private
926
- * @param {module:engine/view/element~Element} element
927
- * @returns {String}
928
- */
929
- _stringifyElementAttributes( element ) {
930
- const attributes = [];
931
- const keys = [ ...element.getAttributeKeys() ].sort();
932
-
933
- for ( const attribute of keys ) {
934
- let attributeValue;
935
-
936
- if ( attribute === 'class' ) {
937
- attributeValue = [ ...element.getClassNames() ]
938
- .sort()
939
- .join( ' ' );
940
- } else if ( attribute === 'style' ) {
941
- attributeValue = [ ...element.getStyleNames() ]
942
- .sort()
943
- .map( style => `${ style }:${ element.getStyle( style ) }` )
944
- .join( ';' );
945
- } else {
946
- attributeValue = element.getAttribute( attribute );
947
- }
948
-
949
- attributes.push( `${ attribute }="${ attributeValue }"` );
950
- }
951
-
952
- return attributes.join( ' ' );
953
- }
561
+ /**
562
+ * Creates a view stringify instance.
563
+ *
564
+ * @param root
565
+ * @param {module:engine/view/documentselection~DocumentSelection} selection A selection whose ranges
566
+ * should also be converted to a string.
567
+ * @param {Object} options An options object.
568
+ * @param {Boolean} [options.showType=false] When set to `true`, the type of elements will be printed (`<container:p>`
569
+ * instead of `<p>`, `<attribute:b>` instead of `<b>` and `<empty:img>` instead of `<img>`).
570
+ * @param {Boolean} [options.showPriority=false] When set to `true`, the attribute element's priority will be printed.
571
+ * @param {Boolean} [options.ignoreRoot=false] When set to `true`, the root's element opening and closing tag will not
572
+ * be outputted.
573
+ * @param {Boolean} [options.sameSelectionCharacters=false] When set to `true`, the selection inside the text is marked as
574
+ * `{` and `}` and the selection outside the text as `[` and `]`. When set to `false`, both are marked as `[` and `]`.
575
+ * @param {Boolean} [options.renderUIElements=false] When set to `true`, the inner content of each
576
+ * {@link module:engine/view/uielement~UIElement} will be printed.
577
+ * @param {Boolean} [options.renderRawElements=false] When set to `true`, the inner content of each
578
+ * @param {Object} [options.domConverter={}] When set to an actual {@link module:engine/view/domconverter~DomConverter DomConverter}
579
+ * instance, it lets the conversion go through exactly the same flow the editing view is going through,
580
+ * i.e. with view data filtering. Otherwise the simple stub is used.
581
+ * {@link module:engine/view/rawelement~RawElement} will be printed.
582
+ */
583
+ constructor(root, selection, options) {
584
+ this.root = root;
585
+ this.selection = selection;
586
+ this.ranges = [];
587
+ if (selection) {
588
+ this.ranges = [...selection.getRanges()];
589
+ }
590
+ this.showType = !!options.showType;
591
+ this.showPriority = !!options.showPriority;
592
+ this.showAttributeElementId = !!options.showAttributeElementId;
593
+ this.ignoreRoot = !!options.ignoreRoot;
594
+ this.sameSelectionCharacters = !!options.sameSelectionCharacters;
595
+ this.renderUIElements = !!options.renderUIElements;
596
+ this.renderRawElements = !!options.renderRawElements;
597
+ this.domConverter = options.domConverter || domConverterStub;
598
+ }
599
+ /**
600
+ * Converts the view to a string.
601
+ *
602
+ * @returns {String} String representation of the view elements.
603
+ */
604
+ stringify() {
605
+ let result = '';
606
+ this._walkView(this.root, chunk => {
607
+ result += chunk;
608
+ });
609
+ return result;
610
+ }
611
+ /**
612
+ * Executes a simple walker that iterates over all elements in the view tree starting from the root element.
613
+ * Calls the `callback` with parsed chunks of string data.
614
+ *
615
+ * @private
616
+ * @param {module:engine/view/documentfragment~DocumentFragment|module:engine/view/element~Element|module:engine/view/text~Text} root
617
+ * @param {Function} callback
618
+ */
619
+ _walkView(root, callback) {
620
+ const ignore = this.ignoreRoot && this.root === root;
621
+ if (root.is('element') || root.is('documentFragment')) {
622
+ if (root.is('element') && !ignore) {
623
+ callback(this._stringifyElementOpen(root));
624
+ }
625
+ if ((this.renderUIElements && root.is('uiElement'))) {
626
+ callback(root.render(document, this.domConverter).innerHTML);
627
+ }
628
+ else if (this.renderRawElements && root.is('rawElement')) {
629
+ // There's no DOM element for "root" to pass to render(). Creating
630
+ // a surrogate container to render the children instead.
631
+ const rawContentContainer = document.createElement('div');
632
+ root.render(rawContentContainer, this.domConverter);
633
+ callback(rawContentContainer.innerHTML);
634
+ }
635
+ else {
636
+ let offset = 0;
637
+ callback(this._stringifyElementRanges(root, offset));
638
+ for (const child of root.getChildren()) {
639
+ this._walkView(child, callback);
640
+ offset++;
641
+ callback(this._stringifyElementRanges(root, offset));
642
+ }
643
+ }
644
+ if (root.is('element') && !ignore) {
645
+ callback(this._stringifyElementClose(root));
646
+ }
647
+ }
648
+ if (root.is('$text')) {
649
+ callback(this._stringifyTextRanges(root));
650
+ }
651
+ }
652
+ /**
653
+ * Checks if a given {@link module:engine/view/element~Element element} has a {@link module:engine/view/range~Range#start range start}
654
+ * or a {@link module:engine/view/range~Range#start range end} placed at a given offset and returns its string representation.
655
+ *
656
+ * @private
657
+ * @param {module:engine/view/element~Element} element
658
+ * @param {Number} offset
659
+ */
660
+ _stringifyElementRanges(element, offset) {
661
+ let start = '';
662
+ let end = '';
663
+ let collapsed = '';
664
+ for (const range of this.ranges) {
665
+ if (range.start.parent == element && range.start.offset === offset) {
666
+ if (range.isCollapsed) {
667
+ collapsed += ELEMENT_RANGE_START_TOKEN + ELEMENT_RANGE_END_TOKEN;
668
+ }
669
+ else {
670
+ start += ELEMENT_RANGE_START_TOKEN;
671
+ }
672
+ }
673
+ if (range.end.parent === element && range.end.offset === offset && !range.isCollapsed) {
674
+ end += ELEMENT_RANGE_END_TOKEN;
675
+ }
676
+ }
677
+ return end + collapsed + start;
678
+ }
679
+ /**
680
+ * Checks if a given {@link module:engine/view/element~Element Text node} has a
681
+ * {@link module:engine/view/range~Range#start range start} or a
682
+ * {@link module:engine/view/range~Range#start range end} placed somewhere inside. Returns a string representation of text
683
+ * with range delimiters placed inside.
684
+ *
685
+ * @private
686
+ * @param {module:engine/view/text~Text} node
687
+ */
688
+ _stringifyTextRanges(node) {
689
+ const length = node.data.length;
690
+ const data = node.data.split('');
691
+ let rangeStartToken, rangeEndToken;
692
+ if (this.sameSelectionCharacters) {
693
+ rangeStartToken = ELEMENT_RANGE_START_TOKEN;
694
+ rangeEndToken = ELEMENT_RANGE_END_TOKEN;
695
+ }
696
+ else {
697
+ rangeStartToken = TEXT_RANGE_START_TOKEN;
698
+ rangeEndToken = TEXT_RANGE_END_TOKEN;
699
+ }
700
+ // Add one more element for ranges ending after last character in text.
701
+ data[length] = '';
702
+ // Represent each letter as object with information about opening/closing ranges at each offset.
703
+ const result = data.map(letter => {
704
+ return {
705
+ letter,
706
+ start: '',
707
+ end: '',
708
+ collapsed: ''
709
+ };
710
+ });
711
+ for (const range of this.ranges) {
712
+ const start = range.start;
713
+ const end = range.end;
714
+ if (start.parent == node && start.offset >= 0 && start.offset <= length) {
715
+ if (range.isCollapsed) {
716
+ result[end.offset].collapsed += rangeStartToken + rangeEndToken;
717
+ }
718
+ else {
719
+ result[start.offset].start += rangeStartToken;
720
+ }
721
+ }
722
+ if (end.parent == node && end.offset >= 0 && end.offset <= length && !range.isCollapsed) {
723
+ result[end.offset].end += rangeEndToken;
724
+ }
725
+ }
726
+ return result.map(item => item.end + item.collapsed + item.start + item.letter).join('');
727
+ }
728
+ /**
729
+ * Converts the passed {@link module:engine/view/element~Element element} to an opening tag.
730
+ *
731
+ * Depending on the current configuration, the opening tag can be simple (`<a>`), contain a type prefix (`<container:p>`,
732
+ * `<attribute:a>` or `<empty:img>`), contain priority information ( `<attribute:a view-priority="20">` ),
733
+ * or contain element id ( `<attribute:span view-id="foo">` ). Element attributes will also be included
734
+ * (`<a href="https://ckeditor.com" name="foobar">`).
735
+ *
736
+ * @private
737
+ * @param {module:engine/view/element~Element} element
738
+ * @returns {String}
739
+ */
740
+ _stringifyElementOpen(element) {
741
+ const priority = this._stringifyElementPriority(element);
742
+ const id = this._stringifyElementId(element);
743
+ const type = this._stringifyElementType(element);
744
+ const name = [type, element.name].filter(i => i !== '').join(':');
745
+ const attributes = this._stringifyElementAttributes(element);
746
+ const parts = [name, priority, id, attributes];
747
+ return `<${parts.filter(i => i !== '').join(' ')}>`;
748
+ }
749
+ /**
750
+ * Converts the passed {@link module:engine/view/element~Element element} to a closing tag.
751
+ * Depending on the current configuration, the closing tag can be simple (`</a>`) or contain a type prefix (`</container:p>`,
752
+ * `</attribute:a>` or `</empty:img>`).
753
+ *
754
+ * @private
755
+ * @param {module:engine/view/element~Element} element
756
+ * @returns {String}
757
+ */
758
+ _stringifyElementClose(element) {
759
+ const type = this._stringifyElementType(element);
760
+ const name = [type, element.name].filter(i => i !== '').join(':');
761
+ return `</${name}>`;
762
+ }
763
+ /**
764
+ * Converts the passed {@link module:engine/view/element~Element element's} type to its string representation
765
+ *
766
+ * Returns:
767
+ * * 'attribute' for {@link module:engine/view/attributeelement~AttributeElement attribute elements},
768
+ * * 'container' for {@link module:engine/view/containerelement~ContainerElement container elements},
769
+ * * 'empty' for {@link module:engine/view/emptyelement~EmptyElement empty elements},
770
+ * * 'ui' for {@link module:engine/view/uielement~UIElement UI elements},
771
+ * * 'raw' for {@link module:engine/view/rawelement~RawElement raw elements},
772
+ * * an empty string when the current configuration is preventing showing elements' types.
773
+ *
774
+ * @private
775
+ * @param {module:engine/view/element~Element} element
776
+ * @returns {String}
777
+ */
778
+ _stringifyElementType(element) {
779
+ if (this.showType) {
780
+ for (const type in allowedTypes) {
781
+ if (element instanceof allowedTypes[type]) {
782
+ return type;
783
+ }
784
+ }
785
+ }
786
+ return '';
787
+ }
788
+ /**
789
+ * Converts the passed {@link module:engine/view/element~Element element} to its priority representation.
790
+ *
791
+ * The priority string representation will be returned when the passed element is an instance of
792
+ * {@link module:engine/view/attributeelement~AttributeElement attribute element} and the current configuration allows to show the
793
+ * priority. Otherwise returns an empty string.
794
+ *
795
+ * @private
796
+ * @param {module:engine/view/element~Element} element
797
+ * @returns {String}
798
+ */
799
+ _stringifyElementPriority(element) {
800
+ if (this.showPriority && element.is('attributeElement')) {
801
+ return `view-priority="${element.priority}"`;
802
+ }
803
+ return '';
804
+ }
805
+ /**
806
+ * Converts the passed {@link module:engine/view/element~Element element} to its id representation.
807
+ *
808
+ * The id string representation will be returned when the passed element is an instance of
809
+ * {@link module:engine/view/attributeelement~AttributeElement attribute element}, the element has an id
810
+ * and the current configuration allows to show the id. Otherwise returns an empty string.
811
+ *
812
+ * @private
813
+ * @param {module:engine/view/element~Element} element
814
+ * @returns {String}
815
+ */
816
+ _stringifyElementId(element) {
817
+ if (this.showAttributeElementId && element.is('attributeElement') && element.id) {
818
+ return `view-id="${element.id}"`;
819
+ }
820
+ return '';
821
+ }
822
+ /**
823
+ * Converts the passed {@link module:engine/view/element~Element element} attributes to their string representation.
824
+ * If an element has no attributes, an empty string is returned.
825
+ *
826
+ * @private
827
+ * @param {module:engine/view/element~Element} element
828
+ * @returns {String}
829
+ */
830
+ _stringifyElementAttributes(element) {
831
+ const attributes = [];
832
+ const keys = [...element.getAttributeKeys()].sort();
833
+ for (const attribute of keys) {
834
+ let attributeValue;
835
+ if (attribute === 'class') {
836
+ attributeValue = [...element.getClassNames()]
837
+ .sort()
838
+ .join(' ');
839
+ }
840
+ else if (attribute === 'style') {
841
+ attributeValue = [...element.getStyleNames()]
842
+ .sort()
843
+ .map(style => `${style}:${element.getStyle(style)}`)
844
+ .join(';');
845
+ }
846
+ else {
847
+ attributeValue = element.getAttribute(attribute);
848
+ }
849
+ attributes.push(`${attribute}="${attributeValue}"`);
850
+ }
851
+ return attributes.join(' ');
852
+ }
954
853
  }
955
-
956
854
  // Converts {@link module:engine/view/element~Element elements} to
957
855
  // {@link module:engine/view/attributeelement~AttributeElement attribute elements},
958
856
  // {@link module:engine/view/containerelement~ContainerElement container elements},
@@ -965,35 +863,31 @@ class ViewStringify {
965
863
  // rootNode The root node to convert.
966
864
  // @returns {module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment|
967
865
  // module:engine/view/text~Text} The root node of converted elements.
968
- function _convertViewElements( rootNode ) {
969
- if ( rootNode.is( 'element' ) || rootNode.is( 'documentFragment' ) ) {
970
- // Convert element or leave document fragment.
971
-
972
- const convertedElement = rootNode.is( 'documentFragment' ) ?
973
- new ViewDocumentFragment( rootNode.document ) :
974
- _convertElement( rootNode.document, rootNode );
975
-
976
- // Convert all child nodes.
977
- // Cache the nodes in array. Otherwise, we would skip some nodes because during iteration we move nodes
978
- // from `rootNode` to `convertedElement`. This would interfere with iteration.
979
- for ( const child of [ ...rootNode.getChildren() ] ) {
980
- if ( convertedElement.is( 'emptyElement' ) ) {
981
- throw new Error( 'Parse error - cannot parse inside EmptyElement.' );
982
- } else if ( convertedElement.is( 'uiElement' ) ) {
983
- throw new Error( 'Parse error - cannot parse inside UIElement.' );
984
- } else if ( convertedElement.is( 'rawElement' ) ) {
985
- throw new Error( 'Parse error - cannot parse inside RawElement.' );
986
- }
987
-
988
- convertedElement._appendChild( _convertViewElements( child ) );
989
- }
990
-
991
- return convertedElement;
992
- }
993
-
994
- return rootNode;
866
+ function _convertViewElements(rootNode) {
867
+ if (rootNode.is('element') || rootNode.is('documentFragment')) {
868
+ // Convert element or leave document fragment.
869
+ const convertedElement = rootNode.is('documentFragment') ?
870
+ new ViewDocumentFragment(rootNode.document) :
871
+ _convertElement(rootNode.document, rootNode);
872
+ // Convert all child nodes.
873
+ // Cache the nodes in array. Otherwise, we would skip some nodes because during iteration we move nodes
874
+ // from `rootNode` to `convertedElement`. This would interfere with iteration.
875
+ for (const child of [...rootNode.getChildren()]) {
876
+ if (convertedElement.is('emptyElement')) {
877
+ throw new Error('Parse error - cannot parse inside EmptyElement.');
878
+ }
879
+ else if (convertedElement.is('uiElement')) {
880
+ throw new Error('Parse error - cannot parse inside UIElement.');
881
+ }
882
+ else if (convertedElement.is('rawElement')) {
883
+ throw new Error('Parse error - cannot parse inside RawElement.');
884
+ }
885
+ convertedElement._appendChild(_convertViewElements(child));
886
+ }
887
+ return convertedElement;
888
+ }
889
+ return rootNode;
995
890
  }
996
-
997
891
  // Converts an {@link module:engine/view/element~Element element} to
998
892
  // {@link module:engine/view/attributeelement~AttributeElement attribute element},
999
893
  // {@link module:engine/view/containerelement~ContainerElement container element},
@@ -1017,29 +911,24 @@ function _convertViewElements( rootNode ) {
1017
911
  // module:engine/view/emptyelement~EmptyElement|module:engine/view/uielement~UIElement|
1018
912
  // module:engine/view/containerelement~ContainerElement} A tree view
1019
913
  // element converted according to its name.
1020
- function _convertElement( viewDocument, viewElement ) {
1021
- const info = _convertElementNameAndInfo( viewElement );
1022
- const ElementConstructor = allowedTypes[ info.type ];
1023
- const newElement = ElementConstructor ? new ElementConstructor( viewDocument, info.name ) : new ViewElement( viewDocument, info.name );
1024
-
1025
- if ( newElement.is( 'attributeElement' ) ) {
1026
- if ( info.priority !== null ) {
1027
- newElement._priority = info.priority;
1028
- }
1029
-
1030
- if ( info.id !== null ) {
1031
- newElement._id = info.id;
1032
- }
1033
- }
1034
-
1035
- // Move attributes.
1036
- for ( const attributeKey of viewElement.getAttributeKeys() ) {
1037
- newElement._setAttribute( attributeKey, viewElement.getAttribute( attributeKey ) );
1038
- }
1039
-
1040
- return newElement;
914
+ function _convertElement(viewDocument, viewElement) {
915
+ const info = _convertElementNameAndInfo(viewElement);
916
+ const ElementConstructor = allowedTypes[info.type];
917
+ const newElement = ElementConstructor ? new ElementConstructor(viewDocument, info.name) : new ViewElement(viewDocument, info.name);
918
+ if (newElement.is('attributeElement')) {
919
+ if (info.priority !== null) {
920
+ newElement._priority = info.priority;
921
+ }
922
+ if (info.id !== null) {
923
+ newElement._id = info.id;
924
+ }
925
+ }
926
+ // Move attributes.
927
+ for (const attributeKey of viewElement.getAttributeKeys()) {
928
+ newElement._setAttribute(attributeKey, viewElement.getAttribute(attributeKey));
929
+ }
930
+ return newElement;
1041
931
  }
1042
-
1043
932
  // Converts the `view-priority` attribute and the {@link module:engine/view/element~Element#name element's name} information needed for
1044
933
  // creating {@link module:engine/view/attributeelement~AttributeElement attribute element},
1045
934
  // {@link module:engine/view/containerelement~ContainerElement container element},
@@ -1053,57 +942,47 @@ function _convertElement( viewDocument, viewElement ) {
1053
942
  // @returns {String} info.name The parsed name of the element.
1054
943
  // @returns {String|null} info.type The parsed type of the element. It can be `attribute`, `container` or `empty`.
1055
944
  // returns {Number|null} info.priority The parsed priority of the element.
1056
- function _convertElementNameAndInfo( viewElement ) {
1057
- const parts = viewElement.name.split( ':' );
1058
-
1059
- const priority = _convertPriority( viewElement.getAttribute( 'view-priority' ) );
1060
- const id = viewElement.hasAttribute( 'view-id' ) ? viewElement.getAttribute( 'view-id' ) : null;
1061
-
1062
- viewElement._removeAttribute( 'view-priority' );
1063
- viewElement._removeAttribute( 'view-id' );
1064
-
1065
- if ( parts.length == 1 ) {
1066
- return {
1067
- name: parts[ 0 ],
1068
- type: priority !== null ? 'attribute' : null,
1069
- priority,
1070
- id
1071
- };
1072
- }
1073
-
1074
- // Check if type and name: container:div.
1075
- const type = _convertType( parts[ 0 ] );
1076
-
1077
- if ( type ) {
1078
- return {
1079
- name: parts[ 1 ],
1080
- type,
1081
- priority,
1082
- id
1083
- };
1084
- }
1085
-
1086
- throw new Error( `Parse error - cannot parse element's name: ${ viewElement.name }.` );
945
+ function _convertElementNameAndInfo(viewElement) {
946
+ const parts = viewElement.name.split(':');
947
+ const priority = _convertPriority(viewElement.getAttribute('view-priority'));
948
+ const id = viewElement.hasAttribute('view-id') ? viewElement.getAttribute('view-id') : null;
949
+ viewElement._removeAttribute('view-priority');
950
+ viewElement._removeAttribute('view-id');
951
+ if (parts.length == 1) {
952
+ return {
953
+ name: parts[0],
954
+ type: priority !== null ? 'attribute' : null,
955
+ priority,
956
+ id
957
+ };
958
+ }
959
+ // Check if type and name: container:div.
960
+ const type = _convertType(parts[0]);
961
+ if (type) {
962
+ return {
963
+ name: parts[1],
964
+ type,
965
+ priority,
966
+ id
967
+ };
968
+ }
969
+ throw new Error(`Parse error - cannot parse element's name: ${viewElement.name}.`);
1087
970
  }
1088
-
1089
971
  // Checks if the element's type is allowed. Returns `attribute`, `container`, `empty` or `null`.
1090
972
  //
1091
973
  // @param {String} type
1092
974
  // @returns {String|null}
1093
- function _convertType( type ) {
1094
- return allowedTypes[ type ] ? type : null;
975
+ function _convertType(type) {
976
+ return type in allowedTypes ? type : null;
1095
977
  }
1096
-
1097
978
  // Checks if a given priority is allowed. Returns null if the priority cannot be converted.
1098
979
  //
1099
980
  // @param {String} priorityString
1100
981
  // returns {Number|null}
1101
- function _convertPriority( priorityString ) {
1102
- const priority = parseInt( priorityString, 10 );
1103
-
1104
- if ( !isNaN( priority ) ) {
1105
- return priority;
1106
- }
1107
-
1108
- return null;
982
+ function _convertPriority(priorityString) {
983
+ const priority = parseInt(priorityString, 10);
984
+ if (!isNaN(priority)) {
985
+ return priority;
986
+ }
987
+ return null;
1109
988
  }