-
-
Notifications
You must be signed in to change notification settings - Fork 3.7k
/
domconverter.js
1278 lines (1089 loc) · 45.2 KB
/
domconverter.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module engine/view/domconverter
*/
/* globals document, Node, NodeFilter, Text */
import ViewText from './text';
import ViewElement from './element';
import ViewPosition from './position';
import ViewRange from './range';
import ViewSelection from './selection';
import ViewDocumentFragment from './documentfragment';
import ViewTreeWalker from './treewalker';
import { BR_FILLER, getDataWithoutFiller, INLINE_FILLER_LENGTH, isInlineFiller, NBSP_FILLER, startsWithFiller } from './filler';
import global from '@ckeditor/ckeditor5-utils/src/dom/global';
import indexOf from '@ckeditor/ckeditor5-utils/src/dom/indexof';
import getAncestors from '@ckeditor/ckeditor5-utils/src/dom/getancestors';
import getCommonAncestor from '@ckeditor/ckeditor5-utils/src/dom/getcommonancestor';
import isText from '@ckeditor/ckeditor5-utils/src/dom/istext';
import { isElement } from 'lodash-es';
// eslint-disable-next-line new-cap
const BR_FILLER_REF = BR_FILLER( document );
/**
* DomConverter is a set of tools to do transformations between DOM nodes and view nodes. It also handles
* {@link module:engine/view/domconverter~DomConverter#bindElements binding} these nodes.
*
* The instance of DOMConverter is available in {@link module:engine/view/view~View#domConverter `editor.editing.view.domConverter`}.
*
* DomConverter does not check which nodes should be rendered (use {@link module:engine/view/renderer~Renderer}), does not keep a
* state of a tree nor keeps synchronization between tree view and DOM tree (use {@link module:engine/view/document~Document}).
*
* DomConverter keeps DOM elements to View element bindings, so when the converter will be destroyed, the binding will
* be lost. Two converters will keep separate binding maps, so one tree view can be bound with two DOM trees.
*/
export default class DomConverter {
/**
* Creates DOM converter.
*
* @param {module:engine/view/document~Document} document The view document instance.
* @param {Object} options Object with configuration options.
* @param {module:engine/view/filler~BlockFillerMode} [options.blockFillerMode='br'] The type of the block filler to use.
*/
constructor( document, options = {} ) {
/**
* @readonly
* @type {module:engine/view/document~Document}
*/
this.document = document;
/**
* The mode of a block filler used by DOM converter.
*
* @readonly
* @member {'br'|'nbsp'} module:engine/view/domconverter~DomConverter#blockFillerMode
*/
this.blockFillerMode = options.blockFillerMode || 'br';
/**
* Elements which are considered pre-formatted elements.
*
* @readonly
* @member {Array.<String>} module:engine/view/domconverter~DomConverter#preElements
*/
this.preElements = [ 'pre' ];
/**
* Elements which are considered block elements (and hence should be filled with a
* {@link #isBlockFiller block filler}).
*
* Whether an element is considered a block element also affects handling of trailing whitespaces.
*
* You can extend this array if you introduce support for block elements which are not yet recognized here.
*
* @readonly
* @member {Array.<String>} module:engine/view/domconverter~DomConverter#blockElements
*/
this.blockElements = [ 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'dd', 'dt', 'figcaption' ];
/**
* Block {@link module:engine/view/filler filler} creator, which is used to create all block fillers during the
* view to DOM conversion and to recognize block fillers during the DOM to view conversion.
*
* @readonly
* @private
* @member {Function} module:engine/view/domconverter~DomConverter#_blockFiller
*/
this._blockFiller = this.blockFillerMode == 'br' ? BR_FILLER : NBSP_FILLER;
/**
* DOM to View mapping.
*
* @private
* @member {WeakMap} module:engine/view/domconverter~DomConverter#_domToViewMapping
*/
this._domToViewMapping = new WeakMap();
/**
* View to DOM mapping.
*
* @private
* @member {WeakMap} module:engine/view/domconverter~DomConverter#_viewToDomMapping
*/
this._viewToDomMapping = new WeakMap();
/**
* Holds mapping between fake selection containers and corresponding view selections.
*
* @private
* @member {WeakMap} module:engine/view/domconverter~DomConverter#_fakeSelectionMapping
*/
this._fakeSelectionMapping = new WeakMap();
}
/**
* Binds given DOM element that represents fake selection to a **position** of a
* {@link module:engine/view/documentselection~DocumentSelection document selection}.
* Document selection copy is stored and can be retrieved by
* {@link module:engine/view/domconverter~DomConverter#fakeSelectionToView} method.
*
* @param {HTMLElement} domElement
* @param {module:engine/view/documentselection~DocumentSelection} viewDocumentSelection
*/
bindFakeSelection( domElement, viewDocumentSelection ) {
this._fakeSelectionMapping.set( domElement, new ViewSelection( viewDocumentSelection ) );
}
/**
* Returns {@link module:engine/view/selection~Selection view selection} instance corresponding to
* given DOM element that represents fake selection. Returns `undefined` if binding to given DOM element does not exists.
*
* @param {HTMLElement} domElement
* @returns {module:engine/view/selection~Selection|undefined}
*/
fakeSelectionToView( domElement ) {
return this._fakeSelectionMapping.get( domElement );
}
/**
* Binds DOM and View elements, so it will be possible to get corresponding elements using
* {@link module:engine/view/domconverter~DomConverter#mapDomToView} and
* {@link module:engine/view/domconverter~DomConverter#mapViewToDom}.
*
* @param {HTMLElement} domElement DOM element to bind.
* @param {module:engine/view/element~Element} viewElement View element to bind.
*/
bindElements( domElement, viewElement ) {
this._domToViewMapping.set( domElement, viewElement );
this._viewToDomMapping.set( viewElement, domElement );
}
/**
* Unbinds given `domElement` from the view element it was bound to. Unbinding is deep, meaning that all children of
* `domElement` will be unbound too.
*
* @param {HTMLElement} domElement DOM element to unbind.
*/
unbindDomElement( domElement ) {
const viewElement = this._domToViewMapping.get( domElement );
if ( viewElement ) {
this._domToViewMapping.delete( domElement );
this._viewToDomMapping.delete( viewElement );
for ( const child of domElement.childNodes ) {
this.unbindDomElement( child );
}
}
}
/**
* Binds DOM and View document fragments, so it will be possible to get corresponding document fragments using
* {@link module:engine/view/domconverter~DomConverter#mapDomToView} and
* {@link module:engine/view/domconverter~DomConverter#mapViewToDom}.
*
* @param {DocumentFragment} domFragment DOM document fragment to bind.
* @param {module:engine/view/documentfragment~DocumentFragment} viewFragment View document fragment to bind.
*/
bindDocumentFragments( domFragment, viewFragment ) {
this._domToViewMapping.set( domFragment, viewFragment );
this._viewToDomMapping.set( viewFragment, domFragment );
}
/**
* Converts view to DOM. For all text nodes, not bound elements and document fragments new items will
* be created. For bound elements and document fragments function will return corresponding items.
*
* @param {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment} viewNode
* View node or document fragment to transform.
* @param {Document} domDocument Document which will be used to create DOM nodes.
* @param {Object} [options] Conversion options.
* @param {Boolean} [options.bind=false] Determines whether new elements will be bound.
* @param {Boolean} [options.withChildren=true] If `true`, node's and document fragment's children will be converted too.
* @returns {Node|DocumentFragment} Converted node or DocumentFragment.
*/
viewToDom( viewNode, domDocument, options = {} ) {
if ( viewNode.is( 'text' ) ) {
const textData = this._processDataFromViewText( viewNode );
return domDocument.createTextNode( textData );
} else {
if ( this.mapViewToDom( viewNode ) ) {
return this.mapViewToDom( viewNode );
}
let domElement;
if ( viewNode.is( 'documentFragment' ) ) {
// Create DOM document fragment.
domElement = domDocument.createDocumentFragment();
if ( options.bind ) {
this.bindDocumentFragments( domElement, viewNode );
}
} else if ( viewNode.is( 'uiElement' ) ) {
// UIElement has its own render() method (see #799).
domElement = viewNode.render( domDocument );
if ( options.bind ) {
this.bindElements( domElement, viewNode );
}
return domElement;
} else {
// Create DOM element.
if ( viewNode.hasAttribute( 'xmlns' ) ) {
domElement = domDocument.createElementNS( viewNode.getAttribute( 'xmlns' ), viewNode.name );
} else {
domElement = domDocument.createElement( viewNode.name );
}
if ( options.bind ) {
this.bindElements( domElement, viewNode );
}
// Copy element's attributes.
for ( const key of viewNode.getAttributeKeys() ) {
domElement.setAttribute( key, viewNode.getAttribute( key ) );
}
}
if ( options.withChildren || options.withChildren === undefined ) {
for ( const child of this.viewChildrenToDom( viewNode, domDocument, options ) ) {
domElement.appendChild( child );
}
}
return domElement;
}
}
/**
* Converts children of the view element to DOM using the
* {@link module:engine/view/domconverter~DomConverter#viewToDom} method.
* Additionally, this method adds block {@link module:engine/view/filler filler} to the list of children, if needed.
*
* @param {module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment} viewElement Parent view element.
* @param {Document} domDocument Document which will be used to create DOM nodes.
* @param {Object} options See {@link module:engine/view/domconverter~DomConverter#viewToDom} options parameter.
* @returns {Iterable.<Node>} DOM nodes.
*/
* viewChildrenToDom( viewElement, domDocument, options = {} ) {
const fillerPositionOffset = viewElement.getFillerOffset && viewElement.getFillerOffset();
let offset = 0;
for ( const childView of viewElement.getChildren() ) {
if ( fillerPositionOffset === offset ) {
yield this._blockFiller( domDocument );
}
yield this.viewToDom( childView, domDocument, options );
offset++;
}
if ( fillerPositionOffset === offset ) {
yield this._blockFiller( domDocument );
}
}
/**
* Converts view {@link module:engine/view/range~Range} to DOM range.
* Inline and block {@link module:engine/view/filler fillers} are handled during the conversion.
*
* @param {module:engine/view/range~Range} viewRange View range.
* @returns {Range} DOM range.
*/
viewRangeToDom( viewRange ) {
const domStart = this.viewPositionToDom( viewRange.start );
const domEnd = this.viewPositionToDom( viewRange.end );
const domRange = document.createRange();
domRange.setStart( domStart.parent, domStart.offset );
domRange.setEnd( domEnd.parent, domEnd.offset );
return domRange;
}
/**
* Converts view {@link module:engine/view/position~Position} to DOM parent and offset.
*
* Inline and block {@link module:engine/view/filler fillers} are handled during the conversion.
* If the converted position is directly before inline filler it is moved inside the filler.
*
* @param {module:engine/view/position~Position} viewPosition View position.
* @returns {Object|null} position DOM position or `null` if view position could not be converted to DOM.
* @returns {Node} position.parent DOM position parent.
* @returns {Number} position.offset DOM position offset.
*/
viewPositionToDom( viewPosition ) {
const viewParent = viewPosition.parent;
if ( viewParent.is( 'text' ) ) {
const domParent = this.findCorrespondingDomText( viewParent );
if ( !domParent ) {
// Position is in a view text node that has not been rendered to DOM yet.
return null;
}
let offset = viewPosition.offset;
if ( startsWithFiller( domParent ) ) {
offset += INLINE_FILLER_LENGTH;
}
return { parent: domParent, offset };
} else {
// viewParent is instance of ViewElement.
let domParent, domBefore, domAfter;
if ( viewPosition.offset === 0 ) {
domParent = this.mapViewToDom( viewParent );
if ( !domParent ) {
// Position is in a view element that has not been rendered to DOM yet.
return null;
}
domAfter = domParent.childNodes[ 0 ];
} else {
const nodeBefore = viewPosition.nodeBefore;
domBefore = nodeBefore.is( 'text' ) ?
this.findCorrespondingDomText( nodeBefore ) :
this.mapViewToDom( viewPosition.nodeBefore );
if ( !domBefore ) {
// Position is after a view element that has not been rendered to DOM yet.
return null;
}
domParent = domBefore.parentNode;
domAfter = domBefore.nextSibling;
}
// If there is an inline filler at position return position inside the filler. We should never return
// the position before the inline filler.
if ( isText( domAfter ) && startsWithFiller( domAfter ) ) {
return { parent: domAfter, offset: INLINE_FILLER_LENGTH };
}
const offset = domBefore ? indexOf( domBefore ) + 1 : 0;
return { parent: domParent, offset };
}
}
/**
* Converts DOM to view. For all text nodes, not bound elements and document fragments new items will
* be created. For bound elements and document fragments function will return corresponding items. For
* {@link module:engine/view/filler fillers} `null` will be returned.
* For all DOM elements rendered by {@link module:engine/view/uielement~UIElement} that UIElement will be returned.
*
* @param {Node|DocumentFragment} domNode DOM node or document fragment to transform.
* @param {Object} [options] Conversion options.
* @param {Boolean} [options.bind=false] Determines whether new elements will be bound.
* @param {Boolean} [options.withChildren=true] If `true`, node's and document fragment's children will be converted too.
* @param {Boolean} [options.keepOriginalCase=false] If `false`, node's tag name will be converter to lower case.
* @returns {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment|null} Converted node or document fragment
* or `null` if DOM node is a {@link module:engine/view/filler filler} or the given node is an empty text node.
*/
domToView( domNode, options = {} ) {
if ( this.isBlockFiller( domNode, this.blockFillerMode ) ) {
return null;
}
// When node is inside UIElement return that UIElement as it's view representation.
const uiElement = this.getParentUIElement( domNode, this._domToViewMapping );
if ( uiElement ) {
return uiElement;
}
if ( isText( domNode ) ) {
if ( isInlineFiller( domNode ) ) {
return null;
} else {
const textData = this._processDataFromDomText( domNode );
return textData === '' ? null : new ViewText( this.document, textData );
}
} else if ( this.isComment( domNode ) ) {
return null;
} else {
if ( this.mapDomToView( domNode ) ) {
return this.mapDomToView( domNode );
}
let viewElement;
if ( this.isDocumentFragment( domNode ) ) {
// Create view document fragment.
viewElement = new ViewDocumentFragment( this.document );
if ( options.bind ) {
this.bindDocumentFragments( domNode, viewElement );
}
} else {
// Create view element.
const viewName = options.keepOriginalCase ? domNode.tagName : domNode.tagName.toLowerCase();
viewElement = new ViewElement( this.document, viewName );
if ( options.bind ) {
this.bindElements( domNode, viewElement );
}
// Copy element's attributes.
const attrs = domNode.attributes;
for ( let i = attrs.length - 1; i >= 0; i-- ) {
viewElement._setAttribute( attrs[ i ].name, attrs[ i ].value );
}
}
if ( options.withChildren || options.withChildren === undefined ) {
for ( const child of this.domChildrenToView( domNode, options ) ) {
viewElement._appendChild( child );
}
}
return viewElement;
}
}
/**
* Converts children of the DOM element to view nodes using
* the {@link module:engine/view/domconverter~DomConverter#domToView} method.
* Additionally this method omits block {@link module:engine/view/filler filler}, if it exists in the DOM parent.
*
* @param {HTMLElement} domElement Parent DOM element.
* @param {Object} options See {@link module:engine/view/domconverter~DomConverter#domToView} options parameter.
* @returns {Iterable.<module:engine/view/node~Node>} View nodes.
*/
* domChildrenToView( domElement, options = {} ) {
for ( let i = 0; i < domElement.childNodes.length; i++ ) {
const domChild = domElement.childNodes[ i ];
const viewChild = this.domToView( domChild, options );
if ( viewChild !== null ) {
yield viewChild;
}
}
}
/**
* Converts DOM selection to view {@link module:engine/view/selection~Selection}.
* Ranges which cannot be converted will be omitted.
*
* @param {Selection} domSelection DOM selection.
* @returns {module:engine/view/selection~Selection} View selection.
*/
domSelectionToView( domSelection ) {
// DOM selection might be placed in fake selection container.
// If container contains fake selection - return corresponding view selection.
if ( domSelection.rangeCount === 1 ) {
let container = domSelection.getRangeAt( 0 ).startContainer;
// The DOM selection might be moved to the text node inside the fake selection container.
if ( isText( container ) ) {
container = container.parentNode;
}
const viewSelection = this.fakeSelectionToView( container );
if ( viewSelection ) {
return viewSelection;
}
}
const isBackward = this.isDomSelectionBackward( domSelection );
const viewRanges = [];
for ( let i = 0; i < domSelection.rangeCount; i++ ) {
// DOM Range have correct start and end, no matter what is the DOM Selection direction. So we don't have to fix anything.
const domRange = domSelection.getRangeAt( i );
const viewRange = this.domRangeToView( domRange );
if ( viewRange ) {
viewRanges.push( viewRange );
}
}
return new ViewSelection( viewRanges, { backward: isBackward } );
}
/**
* Converts DOM Range to view {@link module:engine/view/range~Range}.
* If the start or end position can not be converted `null` is returned.
*
* @param {Range} domRange DOM range.
* @returns {module:engine/view/range~Range|null} View range.
*/
domRangeToView( domRange ) {
const viewStart = this.domPositionToView( domRange.startContainer, domRange.startOffset );
const viewEnd = this.domPositionToView( domRange.endContainer, domRange.endOffset );
if ( viewStart && viewEnd ) {
return new ViewRange( viewStart, viewEnd );
}
return null;
}
/**
* Converts DOM parent and offset to view {@link module:engine/view/position~Position}.
*
* If the position is inside a {@link module:engine/view/filler filler} which has no corresponding view node,
* position of the filler will be converted and returned.
*
* If the position is inside DOM element rendered by {@link module:engine/view/uielement~UIElement}
* that position will be converted to view position before that UIElement.
*
* If structures are too different and it is not possible to find corresponding position then `null` will be returned.
*
* @param {Node} domParent DOM position parent.
* @param {Number} domOffset DOM position offset.
* @returns {module:engine/view/position~Position} viewPosition View position.
*/
domPositionToView( domParent, domOffset ) {
if ( this.isBlockFiller( domParent, this.blockFillerMode ) ) {
return this.domPositionToView( domParent.parentNode, indexOf( domParent ) );
}
// If position is somewhere inside UIElement - return position before that element.
const viewElement = this.mapDomToView( domParent );
if ( viewElement && viewElement.is( 'uiElement' ) ) {
return ViewPosition._createBefore( viewElement );
}
if ( isText( domParent ) ) {
if ( isInlineFiller( domParent ) ) {
return this.domPositionToView( domParent.parentNode, indexOf( domParent ) );
}
const viewParent = this.findCorrespondingViewText( domParent );
let offset = domOffset;
if ( !viewParent ) {
return null;
}
if ( startsWithFiller( domParent ) ) {
offset -= INLINE_FILLER_LENGTH;
offset = offset < 0 ? 0 : offset;
}
return new ViewPosition( viewParent, offset );
}
// domParent instanceof HTMLElement.
else {
if ( domOffset === 0 ) {
const viewParent = this.mapDomToView( domParent );
if ( viewParent ) {
return new ViewPosition( viewParent, 0 );
}
} else {
const domBefore = domParent.childNodes[ domOffset - 1 ];
const viewBefore = isText( domBefore ) ?
this.findCorrespondingViewText( domBefore ) :
this.mapDomToView( domBefore );
// TODO #663
if ( viewBefore && viewBefore.parent ) {
return new ViewPosition( viewBefore.parent, viewBefore.index + 1 );
}
}
return null;
}
}
/**
* Returns corresponding view {@link module:engine/view/element~Element Element} or
* {@link module:engine/view/documentfragment~DocumentFragment} for provided DOM element or
* document fragment. If there is no view item {@link module:engine/view/domconverter~DomConverter#bindElements bound}
* to the given DOM - `undefined` is returned.
* For all DOM elements rendered by {@link module:engine/view/uielement~UIElement} that UIElement will be returned.
*
* @param {DocumentFragment|Element} domElementOrDocumentFragment DOM element or document fragment.
* @returns {module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment|undefined}
* Corresponding view element, document fragment or `undefined` if no element was bound.
*/
mapDomToView( domElementOrDocumentFragment ) {
return this.getParentUIElement( domElementOrDocumentFragment ) || this._domToViewMapping.get( domElementOrDocumentFragment );
}
/**
* Finds corresponding text node. Text nodes are not {@link module:engine/view/domconverter~DomConverter#bindElements bound},
* corresponding text node is returned based on the sibling or parent.
*
* If the directly previous sibling is a {@link module:engine/view/domconverter~DomConverter#bindElements bound} element, it is used
* to find the corresponding text node.
*
* If this is a first child in the parent and the parent is a {@link module:engine/view/domconverter~DomConverter#bindElements bound}
* element, it is used to find the corresponding text node.
*
* For all text nodes rendered by {@link module:engine/view/uielement~UIElement} that UIElement will be returned.
*
* Otherwise `null` is returned.
*
* Note that for the block or inline {@link module:engine/view/filler filler} this method returns `null`.
*
* @param {Text} domText DOM text node.
* @returns {module:engine/view/text~Text|null} Corresponding view text node or `null`, if it was not possible to find a
* corresponding node.
*/
findCorrespondingViewText( domText ) {
if ( isInlineFiller( domText ) ) {
return null;
}
// If DOM text was rendered by UIElement - return that element.
const uiElement = this.getParentUIElement( domText );
if ( uiElement ) {
return uiElement;
}
const previousSibling = domText.previousSibling;
// Try to use previous sibling to find the corresponding text node.
if ( previousSibling ) {
if ( !( this.isElement( previousSibling ) ) ) {
// The previous is text or comment.
return null;
}
const viewElement = this.mapDomToView( previousSibling );
if ( viewElement ) {
const nextSibling = viewElement.nextSibling;
// It might be filler which has no corresponding view node.
if ( nextSibling instanceof ViewText ) {
return viewElement.nextSibling;
} else {
return null;
}
}
}
// Try to use parent to find the corresponding text node.
else {
const viewElement = this.mapDomToView( domText.parentNode );
if ( viewElement ) {
const firstChild = viewElement.getChild( 0 );
// It might be filler which has no corresponding view node.
if ( firstChild instanceof ViewText ) {
return firstChild;
} else {
return null;
}
}
}
return null;
}
/**
* Returns corresponding DOM item for provided {@link module:engine/view/element~Element Element} or
* {@link module:engine/view/documentfragment~DocumentFragment DocumentFragment}.
* To find a corresponding text for {@link module:engine/view/text~Text view Text instance}
* use {@link #findCorrespondingDomText}.
*
* @param {module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment} viewNode
* View element or document fragment.
* @returns {Node|DocumentFragment|undefined} Corresponding DOM node or document fragment.
*/
mapViewToDom( documentFragmentOrElement ) {
return this._viewToDomMapping.get( documentFragmentOrElement );
}
/**
* Finds corresponding text node. Text nodes are not {@link module:engine/view/domconverter~DomConverter#bindElements bound},
* corresponding text node is returned based on the sibling or parent.
*
* If the directly previous sibling is a {@link module:engine/view/domconverter~DomConverter#bindElements bound} element, it is used
* to find the corresponding text node.
*
* If this is a first child in the parent and the parent is a {@link module:engine/view/domconverter~DomConverter#bindElements bound}
* element, it is used to find the corresponding text node.
*
* Otherwise `null` is returned.
*
* @param {module:engine/view/text~Text} viewText View text node.
* @returns {Text|null} Corresponding DOM text node or `null`, if it was not possible to find a corresponding node.
*/
findCorrespondingDomText( viewText ) {
const previousSibling = viewText.previousSibling;
// Try to use previous sibling to find the corresponding text node.
if ( previousSibling && this.mapViewToDom( previousSibling ) ) {
return this.mapViewToDom( previousSibling ).nextSibling;
}
// If this is a first node, try to use parent to find the corresponding text node.
if ( !previousSibling && viewText.parent && this.mapViewToDom( viewText.parent ) ) {
return this.mapViewToDom( viewText.parent ).childNodes[ 0 ];
}
return null;
}
/**
* Focuses DOM editable that is corresponding to provided {@link module:engine/view/editableelement~EditableElement}.
*
* @param {module:engine/view/editableelement~EditableElement} viewEditable
*/
focus( viewEditable ) {
const domEditable = this.mapViewToDom( viewEditable );
if ( domEditable && domEditable.ownerDocument.activeElement !== domEditable ) {
// Save the scrollX and scrollY positions before the focus.
const { scrollX, scrollY } = global.window;
const scrollPositions = [];
// Save all scrollLeft and scrollTop values starting from domEditable up to
// document#documentElement.
forEachDomNodeAncestor( domEditable, node => {
const { scrollLeft, scrollTop } = node;
scrollPositions.push( [ scrollLeft, scrollTop ] );
} );
domEditable.focus();
// Restore scrollLeft and scrollTop values starting from domEditable up to
// document#documentElement.
// https://github.com/ckeditor/ckeditor5-engine/issues/951
// https://github.com/ckeditor/ckeditor5-engine/issues/957
forEachDomNodeAncestor( domEditable, node => {
const [ scrollLeft, scrollTop ] = scrollPositions.shift();
node.scrollLeft = scrollLeft;
node.scrollTop = scrollTop;
} );
// Restore the scrollX and scrollY positions after the focus.
// https://github.com/ckeditor/ckeditor5-engine/issues/951
global.window.scrollTo( scrollX, scrollY );
}
}
/**
* Returns `true` when `node.nodeType` equals `Node.ELEMENT_NODE`.
*
* @param {Node} node Node to check.
* @returns {Boolean}
*/
isElement( node ) {
return node && node.nodeType == Node.ELEMENT_NODE;
}
/**
* Returns `true` when `node.nodeType` equals `Node.DOCUMENT_FRAGMENT_NODE`.
*
* @param {Node} node Node to check.
* @returns {Boolean}
*/
isDocumentFragment( node ) {
return node && node.nodeType == Node.DOCUMENT_FRAGMENT_NODE;
}
/**
* Returns `true` when `node.nodeType` equals `Node.COMMENT_NODE`.
*
* @param {Node} node Node to check.
* @returns {Boolean}
*/
isComment( node ) {
return node && node.nodeType == Node.COMMENT_NODE;
}
/**
* Checks if the node is an instance of the block filler for this DOM converter.
*
* const converter = new DomConverter( viewDocument, { blockFillerMode: 'br' } );
*
* converter.isBlockFiller( BR_FILLER( document ) ); // true
* converter.isBlockFiller( NBSP_FILLER( document ) ); // false
*
* **Note:**: For the `'nbsp'` mode the method also checks context of a node so it cannot be a detached node.
*
* **Note:** A special case in the `'nbsp'` mode exists where the `<br>` in `<p><br></p>` is treated as a block filler.
*
* @param {Node} domNode DOM node to check.
* @returns {Boolean} True if a node is considered a block filler for given mode.
*/
isBlockFiller( domNode ) {
if ( this.blockFillerMode == 'br' ) {
return domNode.isEqualNode( BR_FILLER_REF );
}
// Special case for <p><br></p> in which case the <br> should be treated as filler even
// when we're in the 'nbsp' mode. See ckeditor5#5564.
if ( domNode.tagName === 'BR' && hasBlockParent( domNode, this.blockElements ) && domNode.parentNode.childNodes.length === 1 ) {
return true;
}
return isNbspBlockFiller( domNode, this.blockElements );
}
/**
* Returns `true` if given selection is a backward selection, that is, if it's `focus` is before `anchor`.
*
* @param {Selection} DOM Selection instance to check.
* @returns {Boolean}
*/
isDomSelectionBackward( selection ) {
if ( selection.isCollapsed ) {
return false;
}
// Since it takes multiple lines of code to check whether a "DOM Position" is before/after another "DOM Position",
// we will use the fact that range will collapse if it's end is before it's start.
const range = document.createRange();
range.setStart( selection.anchorNode, selection.anchorOffset );
range.setEnd( selection.focusNode, selection.focusOffset );
const backward = range.collapsed;
range.detach();
return backward;
}
/**
* Returns parent {@link module:engine/view/uielement~UIElement} for provided DOM node. Returns `null` if there is no
* parent UIElement.
*
* @param {Node} domNode
* @returns {module:engine/view/uielement~UIElement|null}
*/
getParentUIElement( domNode ) {
const ancestors = getAncestors( domNode );
// Remove domNode from the list.
ancestors.pop();
while ( ancestors.length ) {
const domNode = ancestors.pop();
const viewNode = this._domToViewMapping.get( domNode );
if ( viewNode && viewNode.is( 'uiElement' ) ) {
return viewNode;
}
}
return null;
}
/**
* Checks if given selection's boundaries are at correct places.
*
* The following places are considered as incorrect for selection boundaries:
* * before or in the middle of the inline filler sequence,
* * inside the DOM element which represents {@link module:engine/view/uielement~UIElement a view ui element}.
*
* @param {Selection} domSelection DOM Selection object to be checked.
* @returns {Boolean} `true` if the given selection is at a correct place, `false` otherwise.
*/
isDomSelectionCorrect( domSelection ) {
return this._isDomSelectionPositionCorrect( domSelection.anchorNode, domSelection.anchorOffset ) &&
this._isDomSelectionPositionCorrect( domSelection.focusNode, domSelection.focusOffset );
}
/**
* Checks if the given DOM position is a correct place for selection boundary. See {@link #isDomSelectionCorrect}.
*
* @private
* @param {Element} domParent Position parent.
* @param {Number} offset Position offset.
* @returns {Boolean} `true` if given position is at a correct place for selection boundary, `false` otherwise.
*/
_isDomSelectionPositionCorrect( domParent, offset ) {
// If selection is before or in the middle of inline filler string, it is incorrect.
if ( isText( domParent ) && startsWithFiller( domParent ) && offset < INLINE_FILLER_LENGTH ) {
// Selection in a text node, at wrong position (before or in the middle of filler).
return false;
}
if ( this.isElement( domParent ) && startsWithFiller( domParent.childNodes[ offset ] ) ) {
// Selection in an element node, before filler text node.
return false;
}
const viewParent = this.mapDomToView( domParent );
// If selection is in `view.UIElement`, it is incorrect. Note that `mapDomToView()` returns `view.UIElement`
// also for any dom element that is inside the view ui element (so we don't need to perform any additional checks).
if ( viewParent && viewParent.is( 'uiElement' ) ) {
return false;
}
return true;
}
/**
* Takes text data from a given {@link module:engine/view/text~Text#data} and processes it so
* it is correctly displayed in the DOM.
*
* Following changes are done:
*
* * a space at the beginning is changed to ` ` if this is the first text node in its container
* element or if a previous text node ends with a space character,
* * space at the end of the text node is changed to ` ` if there are two spaces at the end of a node or if next node
* starts with a space or if it is the last text node in its container,
* * remaining spaces are replaced to a chain of spaces and ` ` (e.g. `'x x'` becomes `'x x'`).
*
* Content of {@link #preElements} is not processed.
*
* @private
* @param {module:engine/view/text~Text} node View text node to process.
* @returns {String} Processed text data.
*/
_processDataFromViewText( node ) {
let data = node.data;
// If any of node ancestors has a name which is in `preElements` array, then currently processed
// view text node is (will be) in preformatted element. We should not change whitespaces then.
if ( node.getAncestors().some( parent => this.preElements.includes( parent.name ) ) ) {
return data;
}
// 1. Replace the first space with a nbsp if the previous node ends with a space or there is no previous node
// (container element boundary).
if ( data.charAt( 0 ) == ' ' ) {
const prevNode = this._getTouchingViewTextNode( node, false );
const prevEndsWithSpace = prevNode && this._nodeEndsWithSpace( prevNode );
if ( prevEndsWithSpace || !prevNode ) {
data = '\u00A0' + data.substr( 1 );
}
}
// 2. Replace the last space with nbsp if there are two spaces at the end or if the next node starts with space or there is no
// next node (container element boundary).
//
// Keep in mind that Firefox prefers $nbsp; before tag, not inside it:
//
// Foo <span> bar</span> <-- bad.
// Foo <span> bar</span> <-- good.
//
// More here: https://github.com/ckeditor/ckeditor5-engine/issues/1747.
if ( data.charAt( data.length - 1 ) == ' ' ) {
const nextNode = this._getTouchingViewTextNode( node, true );
if ( data.charAt( data.length - 2 ) == ' ' || !nextNode || nextNode.data.charAt( 0 ) == ' ' ) {
data = data.substr( 0, data.length - 1 ) + '\u00A0';
}
}
// 3. Create space+nbsp pairs.
return data.replace( / {2}/g, ' \u00A0' );
}
/**
* Checks whether given node ends with a space character after changing appropriate space characters to ` `s.
*
* @private
* @param {module:engine/view/text~Text} node Node to check.
* @returns {Boolean} `true` if given `node` ends with space, `false` otherwise.
*/
_nodeEndsWithSpace( node ) {
if ( node.getAncestors().some( parent => this.preElements.includes( parent.name ) ) ) {
return false;
}