/
model.ts
1305 lines (1207 loc) · 50.4 KB
/
model.ts
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-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module engine/model/model
*/
import Batch, { type BatchType } from './batch.js';
import Document from './document.js';
import MarkerCollection from './markercollection.js';
import ModelPosition, { type PositionOffset, type PositionStickiness } from './position.js';
import ModelRange from './range.js';
import ModelSelection, { type PlaceOrOffset, type Selectable } from './selection.js';
import OperationFactory from './operation/operationfactory.js';
import DocumentSelection from './documentselection.js';
import Schema from './schema.js';
import Writer from './writer.js';
import Node from './node.js';
import { autoParagraphEmptyRoots } from './utils/autoparagraphing.js';
import { injectSelectionPostFixer } from './utils/selection-post-fixer.js';
import deleteContent from './utils/deletecontent.js';
import getSelectedContent from './utils/getselectedcontent.js';
import insertContent from './utils/insertcontent.js';
import insertObject from './utils/insertobject.js';
import modifySelection from './utils/modifyselection.js';
import type ModelDocumentFragment from './documentfragment.js';
import type Item from './item.js';
import type ModelElement from './element.js';
import type Operation from './operation/operation.js';
import {
CKEditorError,
ObservableMixin,
type DecoratedMethodEvent
} from '@ckeditor/ckeditor5-utils';
// @if CK_DEBUG_ENGINE // const { dumpTrees, initDocumentDumping } = require( '../dev-utils/utils' );
// @if CK_DEBUG_ENGINE // const { OperationReplayer } = require( '../dev-utils/operationreplayer' ).default;
/**
* Editor's data model. Read about the model in the
* {@glink framework/architecture/editing-engine engine architecture} guide.
*/
export default class Model extends ObservableMixin() {
/**
* Model's marker collection.
*/
public readonly markers: MarkerCollection;
/**
* Model's document.
*/
public readonly document: Document;
/**
* Model's schema.
*/
public readonly schema: Schema;
/**
* All callbacks added by {@link module:engine/model/model~Model#change} or
* {@link module:engine/model/model~Model#enqueueChange} methods waiting to be executed.
*/
private readonly _pendingChanges: Array<{ batch: Batch; callback: ( writer: Writer ) => any }>;
/**
* The last created and currently used writer instance.
*/
private _currentWriter: Writer | null;
// @if CK_DEBUG_ENGINE // private _operationLogs: Array<string>;
// @if CK_DEBUG_ENGINE // private _appliedOperations: Array<Operation>;
constructor() {
super();
this.markers = new MarkerCollection();
this.document = new Document( this );
this.schema = new Schema();
this._pendingChanges = [];
this._currentWriter = null;
( [ 'deleteContent', 'modifySelection', 'getSelectedContent', 'applyOperation' ] as const )
.forEach( methodName => this.decorate( methodName ) );
// Adding operation validation with `highest` priority, so it is called before any other feature would like
// to do anything with the operation. If the operation has incorrect parameters it should throw on the earliest occasion.
this.on<ModelApplyOperationEvent>( 'applyOperation', ( evt, args ) => {
const operation = args[ 0 ];
operation._validate();
}, { priority: 'highest' } );
// Register some default abstract entities.
this.schema.register( '$root', {
isLimit: true
} );
this.schema.register( '$container', {
allowIn: [ '$root', '$container' ]
} );
this.schema.register( '$block', {
allowIn: [ '$root', '$container' ],
isBlock: true
} );
this.schema.register( '$blockObject', {
allowWhere: '$block',
isBlock: true,
isObject: true
} );
this.schema.register( '$inlineObject', {
allowWhere: '$text',
allowAttributesOf: '$text',
isInline: true,
isObject: true
} );
this.schema.register( '$text', {
allowIn: '$block',
isInline: true,
isContent: true
} );
this.schema.register( '$clipboardHolder', {
allowContentOf: '$root',
allowChildren: '$text',
isLimit: true
} );
this.schema.register( '$documentFragment', {
allowContentOf: '$root',
allowChildren: '$text',
isLimit: true
} );
// An element needed by the `upcastElementToMarker` converter.
// This element temporarily represents a marker boundary during the conversion process and is removed
// at the end of the conversion. `UpcastDispatcher` or at least `Conversion` class looks like a
// better place for this registration but both know nothing about `Schema`.
this.schema.register( '$marker' );
this.schema.addChildCheck( ( context, childDefinition ) => {
if ( childDefinition.name === '$marker' ) {
return true;
}
} );
injectSelectionPostFixer( this );
// Post-fixer which takes care of adding empty paragraph elements to the empty roots.
this.document.registerPostFixer( autoParagraphEmptyRoots );
// The base implementation for "decorated" method with remapped arguments.
this.on<ModelInsertContentEvent>( 'insertContent', ( evt, [ content, selectable ] ) => {
evt.return = insertContent( this, content, selectable );
} );
// The base implementation for "decorated" method with remapped arguments.
this.on<ModelInsertObjectEvent>( 'insertObject', ( evt, [ element, selection, options ] ) => {
evt.return = insertObject( this, element, selection, options );
} );
// The base implementation for "decorated" method with remapped arguments.
this.on<ModelCanEditAtEvent>( 'canEditAt', evt => {
const canEditAt = !this.document.isReadOnly;
evt.return = canEditAt;
if ( !canEditAt ) {
// Prevent further processing if the selection is at non-editable place.
evt.stop();
}
} );
// @if CK_DEBUG_ENGINE // initDocumentDumping( this.document );
// @if CK_DEBUG_ENGINE // this.on( 'applyOperation', () => {
// @if CK_DEBUG_ENGINE // dumpTrees( this.document, this.document.version );
// @if CK_DEBUG_ENGINE // }, { priority: 'lowest' } );
// @if CK_DEBUG_ENGINE // this._operationLogs = [];
// @if CK_DEBUG_ENGINE // this._appliedOperations = [];
}
/**
* The `change()` method is the primary way of changing the model. You should use it to modify all document nodes
* (including detached nodes – i.e. nodes not added to the {@link module:engine/model/model~Model#document model document}),
* the {@link module:engine/model/document~Document#selection document's selection}, and
* {@link module:engine/model/model~Model#markers model markers}.
*
* ```ts
* model.change( writer => {
* writer.insertText( 'foo', paragraph, 'end' );
* } );
* ```
*
* All changes inside the change block use the same {@link module:engine/model/batch~Batch} so they are combined
* into a single undo step.
*
* ```ts
* model.change( writer => {
* writer.insertText( 'foo', paragraph, 'end' ); // foo.
*
* model.change( writer => {
* writer.insertText( 'bar', paragraph, 'end' ); // foobar.
* } );
*
* writer.insertText( 'bom', paragraph, 'end' ); // foobarbom.
* } );
* ```
*
* The callback of the `change()` block is executed synchronously.
*
* You can also return a value from the change block.
*
* ```ts
* const img = model.change( writer => {
* return writer.createElement( 'img' );
* } );
* ```
*
* @see #enqueueChange
* @typeParam TReturn The return type of the provided callback.
* @param callback Callback function which may modify the model.
*/
public change<TReturn>( callback: ( writer: Writer ) => TReturn ): TReturn {
try {
if ( this._pendingChanges.length === 0 ) {
// If this is the outermost block, create a new batch and start `_runPendingChanges` execution flow.
this._pendingChanges.push( { batch: new Batch(), callback } );
return this._runPendingChanges()[ 0 ];
} else {
// If this is not the outermost block, just execute the callback.
return callback( this._currentWriter! );
}
} catch ( err: any ) {
// @if CK_DEBUG // throw err;
/* istanbul ignore next -- @preserve */
CKEditorError.rethrowUnexpectedError( err, this );
}
}
/**
* The `enqueueChange()` method performs similar task as the {@link #change `change()` method}, with two major differences.
*
* First, the callback of `enqueueChange()` is executed when all other enqueued changes are done. It might be executed
* immediately if it is not nested in any other change block, but if it is nested in another (enqueue)change block,
* it will be delayed and executed after the outermost block.
*
* ```ts
* model.change( writer => {
* console.log( 1 );
*
* model.enqueueChange( writer => {
* console.log( 2 );
* } );
*
* console.log( 3 );
* } ); // Will log: 1, 3, 2.
* ```
*
* In addition to that, the changes enqueued with `enqueueChange()` will be converted separately from the changes
* done in the outer `change()` block.
*
* By default, a new batch with the default {@link module:engine/model/batch~Batch#constructor batch type} is created.
* To define the {@link module:engine/model/batch~Batch} into which you want to add your changes,
* use {@link #enqueueChange:CUSTOM_BATCH `enqueueChange( batchOrType, callback )`}.
*
* @label DEFAULT_BATCH
* @param callback Callback function which may modify the model.
*/
public enqueueChange(
callback: ( writer: Writer ) => unknown
): void;
/**
* The `enqueueChange()` method performs similar task as the {@link #change `change()` method}, with two major differences.
*
* First, the callback of `enqueueChange()` is executed when all other enqueued changes are done. It might be executed
* immediately if it is not nested in any other change block, but if it is nested in another (enqueue)change block,
* it will be delayed and executed after the outermost block.
*
* ```ts
* model.change( new Batch(), writer => {
* console.log( 1 );
*
* model.enqueueChange( new Batch(), writer => {
* console.log( 2 );
* } );
*
* console.log( 3 );
* } ); // Will log: 1, 3, 2.
* ```
*
* In addition to that, the changes enqueued with `enqueueChange()` will be converted separately from the changes
* done in the outer `change()` block.
*
* Second, it lets you define the {@link module:engine/model/batch~Batch} into which you want to add your changes.
* If you want to use default {@link module:engine/model/batch~Batch#constructor batch type}, use
* {@link #enqueueChange:DEFAULT_BATCH `enqueueChange( callback )`}.
*
* ```ts
* model.enqueueChange( { isUndoable: false }, writer => {
* writer.insertText( 'foo', paragraph, 'end' );
* } );
* ```
*
* When using the `enqueueChange()` block you can also add some changes to the batch you used before.
*
* ```ts
* model.enqueueChange( batch, writer => {
* writer.insertText( 'foo', paragraph, 'end' );
* } );
* ```
*
* In order to make a nested `enqueueChange()` create a single undo step together with the changes done in the outer `change()`
* block, you can obtain the batch instance from the {@link module:engine/model/writer~Writer#batch writer} of the outer block.
*
* @label CUSTOM_BATCH
* @param batchOrType A batch or a {@link module:engine/model/batch~Batch#constructor batch type} that should be used in the callback.
* If not defined, a new batch with the default type will be created.
* @param callback Callback function which may modify the model.
*/
public enqueueChange(
batchOrType: Batch | BatchType | undefined,
callback: ( writer: Writer ) => unknown
): void;
public enqueueChange(
batchOrType: Batch | BatchType | ( ( writer: Writer ) => unknown ) | undefined,
callback?: ( writer: Writer ) => unknown
): void {
try {
if ( !batchOrType ) {
batchOrType = new Batch();
} else if ( typeof batchOrType === 'function' ) {
callback = batchOrType;
batchOrType = new Batch();
} else if ( !( batchOrType instanceof Batch ) ) {
batchOrType = new Batch( batchOrType );
}
this._pendingChanges.push( { batch: batchOrType, callback } as any );
if ( this._pendingChanges.length == 1 ) {
this._runPendingChanges();
}
} catch ( err: any ) {
// @if CK_DEBUG // throw err;
/* istanbul ignore next -- @preserve */
CKEditorError.rethrowUnexpectedError( err, this );
}
}
/**
* {@link module:utils/observablemixin~Observable#decorate Decorated} function for applying
* {@link module:engine/model/operation/operation~Operation operations} to the model.
*
* This is a low-level way of changing the model. It is exposed for very specific use cases (like the undo feature).
* Normally, to modify the model, you will want to use {@link module:engine/model/writer~Writer `Writer`}.
* See also {@glink framework/architecture/editing-engine#changing-the-model Changing the model} section
* of the {@glink framework/architecture/editing-engine Editing architecture} guide.
*
* @param operation The operation to apply.
*/
public applyOperation( operation: Operation ): void {
// @if CK_DEBUG_ENGINE // console.log( 'Applying ' + operation );
// @if CK_DEBUG_ENGINE // this._operationLogs.push( JSON.stringify( operation ) );
// @if CK_DEBUG_ENGINE // this._appliedOperations.push( operation );
operation._execute();
}
// @if CK_DEBUG_ENGINE // public getAppliedOperation(): string {
// @if CK_DEBUG_ENGINE // if ( !this._appliedOperations ) {
// @if CK_DEBUG_ENGINE // return '';
// @if CK_DEBUG_ENGINE // }
// @if CK_DEBUG_ENGINE // return this._appliedOperations.map( operation => JSON.stringify( operation ) ).join( '-------' );
// @if CK_DEBUG_ENGINE // }
// @if CK_DEBUG_ENGINE // public createReplayer( stringifiedOperations: string ): typeof OperationReplayer {
// @if CK_DEBUG_ENGINE // return new OperationReplayer( this, '-------', stringifiedOperations );
// @if CK_DEBUG_ENGINE // }
/**
* Inserts content at the position in the editor specified by the selection, as one would expect the paste
* functionality to work.
*
* **Note**: If you want to insert an {@glink framework/deep-dive/schema#object-elements object element}
* (e.g. a {@link module:widget/utils~toWidget widget}), see {@link #insertObject} instead.
*
* This is a high-level method. It takes the {@link #schema schema} into consideration when inserting
* the content, clears the given selection's content before inserting nodes and moves the selection
* to its target position at the end of the process.
* It can split elements, merge them, wrap bare text nodes with paragraphs, etc. – just like the
* pasting feature should do.
*
* For lower-level methods see {@link module:engine/model/writer~Writer `Writer`}.
*
* This method, unlike {@link module:engine/model/writer~Writer `Writer`}'s methods, does not have to be used
* inside a {@link #change `change()` block}.
*
* # Conversion and schema
*
* Inserting elements and text nodes into the model is not enough to make CKEditor 5 render that content
* to the user. CKEditor 5 implements a model-view-controller architecture and what `model.insertContent()` does
* is only adding nodes to the model. Additionally, you need to define
* {@glink framework/architecture/editing-engine#conversion converters} between the model and view
* and define those nodes in the {@glink framework/architecture/editing-engine#schema schema}.
*
* So, while this method may seem similar to CKEditor 4 `editor.insertHtml()` (in fact, both methods
* are used for paste-like content insertion), the CKEditor 5 method cannot be use to insert arbitrary HTML
* unless converters are defined for all elements and attributes in that HTML.
*
* # Examples
*
* Using `insertContent()` with a manually created model structure:
*
* ```ts
* // Let's create a document fragment containing such content as:
* //
* // <paragraph>foo</paragraph>
* // <blockQuote>
* // <paragraph>bar</paragraph>
* // </blockQuote>
* const docFrag = editor.model.change( writer => {
* const p1 = writer.createElement( 'paragraph' );
* const p2 = writer.createElement( 'paragraph' );
* const blockQuote = writer.createElement( 'blockQuote' );
* const docFrag = writer.createDocumentFragment();
*
* writer.append( p1, docFrag );
* writer.append( blockQuote, docFrag );
* writer.append( p2, blockQuote );
* writer.insertText( 'foo', p1 );
* writer.insertText( 'bar', p2 );
*
* return docFrag;
* } );
*
* // insertContent() does not have to be used in a change() block. It can, though,
* // so this code could be moved to the callback defined above.
* editor.model.insertContent( docFrag );
* ```
*
* Using `insertContent()` with an HTML string converted to a model document fragment (similar to the pasting mechanism):
*
* ```ts
* // You can create your own HtmlDataProcessor instance or use editor.data.processor
* // if you have not overridden the default one (which is the HtmlDataProcessor instance).
* const htmlDP = new HtmlDataProcessor( viewDocument );
*
* // Convert an HTML string to a view document fragment:
* const viewFragment = htmlDP.toView( htmlString );
*
* // Convert the view document fragment to a model document fragment
* // in the context of $root. This conversion takes the schema into
* // account so if, for example, the view document fragment contained a bare text node,
* // this text node cannot be a child of $root, so it will be automatically
* // wrapped with a <paragraph>. You can define the context yourself (in the second parameter),
* // and e.g. convert the content like it would happen in a <paragraph>.
* // Note: The clipboard feature uses a custom context called $clipboardHolder
* // which has a loosened schema.
* const modelFragment = editor.data.toModel( viewFragment );
*
* editor.model.insertContent( modelFragment );
* ```
*
* By default this method will use the document selection but it can also be used with a position, range or selection instance.
*
* ```ts
* // Insert text at the current document selection position.
* editor.model.change( writer => {
* editor.model.insertContent( writer.createText( 'x' ) );
* } );
*
* // Insert text at a given position - the document selection will not be modified.
* editor.model.change( writer => {
* editor.model.insertContent( writer.createText( 'x' ), doc.getRoot(), 2 );
*
* // Which is a shorthand for:
* editor.model.insertContent( writer.createText( 'x' ), writer.createPositionAt( doc.getRoot(), 2 ) );
* } );
* ```
*
* If you want the document selection to be moved to the inserted content, use the
* {@link module:engine/model/writer~Writer#setSelection `setSelection()`} method of the writer after inserting
* the content:
*
* ```ts
* editor.model.change( writer => {
* const paragraph = writer.createElement( 'paragraph' );
*
* // Insert an empty paragraph at the beginning of the root.
* editor.model.insertContent( paragraph, writer.createPositionAt( editor.model.document.getRoot(), 0 ) );
*
* // Move the document selection to the inserted paragraph.
* writer.setSelection( paragraph, 'in' );
* } );
* ```
*
* If an instance of the {@link module:engine/model/selection~Selection model selection} is passed as `selectable`,
* the new content will be inserted at the passed selection (instead of document selection):
*
* ```ts
* editor.model.change( writer => {
* // Create a selection in a paragraph that will be used as a place of insertion.
* const selection = writer.createSelection( paragraph, 'in' );
*
* // Insert the new text at the created selection.
* editor.model.insertContent( writer.createText( 'x' ), selection );
*
* // insertContent() modifies the passed selection instance so it can be used to set the document selection.
* // Note: This is not necessary when you passed the document selection to insertContent().
* writer.setSelection( selection );
* } );
* ```
*
* @fires insertContent
* @param content The content to insert.
* @param selectable The selection into which the content should be inserted.
* If not provided the current model document selection will be used.
* @param placeOrOffset To be used when a model item was passed as `selectable`.
* This param defines a position in relation to that item.
* at the insertion position.
*/
public insertContent(
content: Item | ModelDocumentFragment,
selectable?: Selectable,
placeOrOffset?: PlaceOrOffset,
...rest: Array<unknown>
): ModelRange {
const selection = normalizeSelectable( selectable, placeOrOffset );
// Passing all call arguments so it acts like decorated method.
return this.fire<ModelInsertContentEvent>( 'insertContent', [ content, selection, placeOrOffset, ...rest ] )!;
}
/**
* Inserts an {@glink framework/deep-dive/schema#object-elements object element} at a specific position in the editor content.
*
* This is a high-level API:
* * It takes the {@link #schema schema} into consideration,
* * It clears the content of passed `selectable` before inserting,
* * It can move the selection at the end of the process,
* * It will copy the selected block's attributes to preserve them upon insertion,
* * It can split elements or wrap inline objects with paragraphs if they are not allowed in target position,
* * etc.
*
* # Notes
*
* * If you want to insert a non-object content, see {@link #insertContent} instead.
* * For lower-level API, see {@link module:engine/model/writer~Writer `Writer`}.
* * Unlike {@link module:engine/model/writer~Writer `Writer`}, this method does not have to be used inside
* a {@link #change `change()` block}.
* * Inserting object into the model is not enough to make CKEditor 5 render that content to the user.
* CKEditor 5 implements a model-view-controller architecture and what `model.insertObject()` does
* is only adding nodes to the model. Additionally, you need to define
* {@glink framework/architecture/editing-engine#conversion converters} between the model and view
* and define those nodes in the {@glink framework/architecture/editing-engine#schema schema}.
*
* # Examples
*
* Use the following code to insert an object at the current selection and keep the selection on the inserted element:
*
* ```ts
* const rawHtmlEmbedElement = writer.createElement( 'rawHtml' );
*
* model.insertObject( rawHtmlEmbedElement, null, null, {
* setSelection: 'on'
* } );
* ```
*
* Use the following code to insert an object at the current selection and nudge the selection after the inserted object:
*
* ```ts
* const pageBreakElement = writer.createElement( 'pageBreak' );
*
* model.insertObject( pageBreakElement, null, null, {
* setSelection: 'after'
* } );
* ```
*
* Use the following code to insert an object at the current selection and avoid splitting the content (non-destructive insertion):
*
* ```ts
* const tableElement = writer.createElement( 'table' );
*
* model.insertObject( tableElement, null, null, {
* findOptimalPosition: 'auto'
* } );
* ```
*
* Use the following code to insert an object at the specific range (also: replace the content of the range):
*
* ```ts
* const tableElement = writer.createElement( 'table' );
* const range = model.createRangeOn( model.document.getRoot().getChild( 1 ) );
*
* model.insertObject( tableElement, range );
* ```
*
* @param element An object to be inserted into the model document.
* @param selectable A selectable where the content should be inserted. If not specified, the current
* {@link module:engine/model/document~Document#selection document selection} will be used instead.
* @param placeOrOffset Specifies the exact place or offset for the insertion to take place, relative to `selectable`.
* @param options Additional options.
* @param options.findOptimalPosition An option that, when set, adjusts the insertion position (relative to
* `selectable` and `placeOrOffset`) so that the content of `selectable` is not split upon insertion (a.k.a. non-destructive insertion).
* * When `'auto'`, the algorithm will decide whether to insert the object before or after `selectable` to avoid content splitting.
* * When `'before'`, the closest position before `selectable` will be used that will not result in content splitting.
* * When `'after'`, the closest position after `selectable` will be used that will not result in content splitting.
*
* Note that this option only works for block objects. Inline objects are inserted into text and do not split blocks.
* @param options.setSelection An option that, when set, moves the
* {@link module:engine/model/document~Document#selection document selection} after inserting the object.
* * When `'on'`, the document selection will be set on the inserted object.
* * When `'after'`, the document selection will move to the closest text node after the inserted object. If there is no
* such text node, a paragraph will be created and the document selection will be moved inside it.
* at the insertion position.
*/
public insertObject(
element: ModelElement,
selectable?: Selectable,
placeOrOffset?: PlaceOrOffset | null,
options?: {
findOptimalPosition?: 'auto' | 'before' | 'after';
setSelection?: 'on' | 'after';
},
...rest: Array<unknown>
): ModelRange {
const selection = normalizeSelectable( selectable, placeOrOffset );
// Note that options are fired as 2 arguments for backward compatibility with the decorated method.
// Passing all call arguments so it acts like decorated method.
return this.fire<ModelInsertObjectEvent>( 'insertObject', [ element, selection, options, options, ...rest ] )!;
}
/**
* Deletes content of the selection and merge siblings. The resulting selection is always collapsed.
*
* **Note:** For the sake of predictability, the resulting selection should always be collapsed.
* In cases where a feature wants to modify deleting behavior so selection isn't collapsed
* (e.g. a table feature may want to keep row selection after pressing <kbd>Backspace</kbd>),
* then that behavior should be implemented in the view's listener. At the same time, the table feature
* will need to modify this method's behavior too, e.g. to "delete contents and then collapse
* the selection inside the last selected cell" or "delete the row and collapse selection somewhere near".
* That needs to be done in order to ensure that other features which use `deleteContent()` will work well with tables.
*
* @fires deleteContent
* @param selection Selection of which the content should be deleted.
* @param options.leaveUnmerged Whether to merge elements after removing the content of the selection.
*
* For example `<heading1>x[x</heading1><paragraph>y]y</paragraph>` will become:
*
* * `<heading1>x^y</heading1>` with the option disabled (`leaveUnmerged == false`)
* * `<heading1>x^</heading1><paragraph>y</paragraph>` with enabled (`leaveUnmerged == true`).
*
* Note: {@link module:engine/model/schema~Schema#isObject object} and {@link module:engine/model/schema~Schema#isLimit limit}
* elements will not be merged.
*
* @param options.doNotResetEntireContent Whether to skip replacing the entire content with a
* paragraph when the entire content was selected.
*
* For example `<heading1>[x</heading1><paragraph>y]</paragraph>` will become:
*
* * `<paragraph>^</paragraph>` with the option disabled (`doNotResetEntireContent == false`)
* * `<heading1>^</heading1>` with enabled (`doNotResetEntireContent == true`)
*
* @param options.doNotAutoparagraph Whether to create a paragraph if after content deletion selection is moved
* to a place where text cannot be inserted.
*
* For example `<paragraph>x</paragraph>[<imageBlock src="foo.jpg"></imageBlock>]` will become:
*
* * `<paragraph>x</paragraph><paragraph>[]</paragraph>` with the option disabled (`doNotAutoparagraph == false`)
* * `<paragraph>x[]</paragraph>` with the option enabled (`doNotAutoparagraph == true`).
*
* **Note:** if there is no valid position for the selection, the paragraph will always be created:
*
* `[<imageBlock src="foo.jpg"></imageBlock>]` -> `<paragraph>[]</paragraph>`.
*
* @param options.direction The direction in which the content is being consumed.
* Deleting backward corresponds to using the <kbd>Backspace</kbd> key, while deleting content forward corresponds to
* the <kbd>Shift</kbd>+<kbd>Backspace</kbd> keystroke.
*/
public deleteContent(
selection: ModelSelection | DocumentSelection,
options?: {
leaveUnmerged?: boolean;
doNotResetEntireContent?: boolean;
doNotAutoparagraph?: boolean;
direction?: 'forward' | 'backward';
[ i: string ]: unknown;
}
): void {
deleteContent( this, selection, options );
}
/**
* Modifies the selection. Currently, the supported modifications are:
*
* * Extending. The selection focus is moved in the specified `options.direction` with a step specified in `options.unit`.
* Possible values for `unit` are:
* * `'character'` (default) - moves selection by one user-perceived character. In most cases this means moving by one
* character in `String` sense. However, unicode also defines "combing marks". These are special symbols, that combines
* with a symbol before it ("base character") to create one user-perceived character. For example, `q̣̇` is a normal
* letter `q` with two "combining marks": upper dot (`Ux0307`) and lower dot (`Ux0323`). For most actions, i.e. extending
* selection by one position, it is correct to include both "base character" and all of it's "combining marks". That is
* why `'character'` value is most natural and common method of modifying selection.
* * `'codePoint'` - moves selection by one unicode code point. In contrary to, `'character'` unit, this will insert
* selection between "base character" and "combining mark", because "combining marks" have their own unicode code points.
* However, for technical reasons, unicode code points with values above `UxFFFF` are represented in native `String` by
* two characters, called "surrogate pairs". Halves of "surrogate pairs" have a meaning only when placed next to each other.
* For example `𨭎` is represented in `String` by `\uD862\uDF4E`. Both `\uD862` and `\uDF4E` do not have any meaning
* outside the pair (are rendered as ? when alone). Position between them would be incorrect. In this case, selection
* extension will include whole "surrogate pair".
* * `'word'` - moves selection by a whole word.
*
* **Note:** if you extend a forward selection in a backward direction you will in fact shrink it.
*
* @fires modifySelection
* @param selection The selection to modify.
* @param options.direction The direction in which the selection should be modified.
* @param options.unit The unit by which selection should be modified.
* @param options.treatEmojiAsSingleUnit Whether multi-characer emoji sequences should be handled as single unit.
*/
public modifySelection(
selection: ModelSelection | DocumentSelection,
options?: {
direction?: 'forward' | 'backward';
unit?: 'character' | 'codePoint' | 'word';
treatEmojiAsSingleUnit?: boolean;
}
): void {
modifySelection( this, selection, options );
}
/**
* Gets a clone of the selected content.
*
* For example, for the following selection:
*
* ```html
* <paragraph>x</paragraph>
* <blockQuote>
* <paragraph>y</paragraph>
* <heading1>fir[st</heading1>
* </blockQuote>
* <paragraph>se]cond</paragraph>
* <paragraph>z</paragraph>
* ```
*
* It will return a document fragment with such a content:
*
* ```html
* <blockQuote>
* <heading1>st</heading1>
* </blockQuote>
* <paragraph>se</paragraph>
* ```
*
* @fires getSelectedContent
* @param selection The selection of which content will be returned.
*/
public getSelectedContent( selection: ModelSelection | DocumentSelection ): ModelDocumentFragment {
return getSelectedContent( this, selection );
}
/**
* Checks whether the given {@link module:engine/model/range~Range range} or
* {@link module:engine/model/element~Element element} has any meaningful content.
*
* Meaningful content is:
*
* * any text node (`options.ignoreWhitespaces` allows controlling whether this text node must also contain
* any non-whitespace characters),
* * or any {@link module:engine/model/schema~Schema#isContent content element},
* * or any {@link module:engine/model/markercollection~Marker marker} which
* {@link module:engine/model/markercollection~Marker#_affectsData affects data}.
*
* This means that a range containing an empty `<paragraph></paragraph>` is not considered to have a meaningful content.
* However, a range containing an `<imageBlock></imageBlock>` (which would normally be marked in the schema as an object element)
* is considered non-empty.
*
* @param rangeOrElement Range or element to check.
* @param options.ignoreWhitespaces Whether text node with whitespaces only should be considered empty.
* @param options.ignoreMarkers Whether markers should be ignored.
*/
public hasContent(
rangeOrElement: ModelRange | ModelElement | ModelDocumentFragment,
options: {
ignoreWhitespaces?: boolean;
ignoreMarkers?: boolean;
} = {}
): boolean {
const range = rangeOrElement instanceof ModelRange ? rangeOrElement : ModelRange._createIn( rangeOrElement );
if ( range.isCollapsed ) {
return false;
}
const { ignoreWhitespaces = false, ignoreMarkers = false } = options;
// Check if there are any markers which affects data in this given range.
if ( !ignoreMarkers ) {
for ( const intersectingMarker of this.markers.getMarkersIntersectingRange( range ) ) {
if ( intersectingMarker.affectsData ) {
return true;
}
}
}
for ( const item of range.getItems() ) {
if ( this.schema.isContent( item ) ) {
if ( item.is( '$textProxy' ) ) {
if ( !ignoreWhitespaces ) {
return true;
} else if ( item.data.search( /\S/ ) !== -1 ) {
return true;
}
} else {
return true;
}
}
}
return false;
}
/**
* Check whether given selectable is at a place in the model where it can be edited (returns `true`) or not (returns `false`).
*
* Should be used instead of {@link module:core/editor/editor~Editor#isReadOnly} to check whether a user action can happen at
* given selectable. It may be decorated and used differently in different environment (e.g. multi-root editor can disable
* a particular root).
*
* This method is decorated. Although this method accepts any parameter of `Selectable` type, the
* {@link ~Model#event:canEditAt `canEditAt` event} is fired with `selectable` normalized to an instance of
* {@link module:engine/model/selection~Selection} or {@link module:engine/model/documentselection~DocumentSelection}
*
* @fires canEditAt
*/
public canEditAt( selectable: Selectable ): boolean {
const selection = normalizeSelectable( selectable );
return this.fire<ModelCanEditAtEvent>( 'canEditAt', [ selection ] )!;
}
/**
* Creates a position from the given root and path in that root.
*
* Note: This method is also available as
* {@link module:engine/model/writer~Writer#createPositionFromPath `Writer#createPositionFromPath()`}.
*
* @param root Root of the position.
* @param path Position path. See {@link module:engine/model/position~Position#path}.
* @param stickiness Position stickiness. See {@link module:engine/model/position~PositionStickiness}.
*/
public createPositionFromPath(
root: ModelElement | ModelDocumentFragment,
path: ReadonlyArray<number>,
stickiness?: PositionStickiness
): ModelPosition {
return new ModelPosition( root, path, stickiness );
}
/**
* Creates position at the given location. The location can be specified as:
*
* * a {@link module:engine/model/position~Position position},
* * a parent element and offset in that element,
* * a parent element and `'end'` (the position will be set at the end of that element),
* * a {@link module:engine/model/item~Item model item} and `'before'` or `'after'`
* (the position will be set before or after the given model item).
*
* This method is a shortcut to other factory methods such as:
*
* * {@link module:engine/model/model~Model#createPositionBefore `createPositionBefore()`},
* * {@link module:engine/model/model~Model#createPositionAfter `createPositionAfter()`}.
*
* Note: This method is also available as
* {@link module:engine/model/writer~Writer#createPositionAt `Writer#createPositionAt()`},
*
* @param itemOrPosition
* @param offset Offset or one of the flags. Used only when first parameter is a {@link module:engine/model/item~Item model item}.
*/
public createPositionAt(
itemOrPosition: Item | ModelPosition | ModelDocumentFragment,
offset?: PositionOffset
): ModelPosition {
return ModelPosition._createAt( itemOrPosition, offset );
}
/**
* Creates a new position after the given {@link module:engine/model/item~Item model item}.
*
* Note: This method is also available as
* {@link module:engine/model/writer~Writer#createPositionAfter `Writer#createPositionAfter()`}.
*
* @param item Item after which the position should be placed.
*/
public createPositionAfter( item: Item ): ModelPosition {
return ModelPosition._createAfter( item );
}
/**
* Creates a new position before the given {@link module:engine/model/item~Item model item}.
*
* Note: This method is also available as
* {@link module:engine/model/writer~Writer#createPositionBefore `Writer#createPositionBefore()`}.
*
* @param item Item before which the position should be placed.
*/
public createPositionBefore( item: Item ): ModelPosition {
return ModelPosition._createBefore( item );
}
/**
* Creates a range spanning from the `start` position to the `end` position.
*
* Note: This method is also available as
* {@link module:engine/model/writer~Writer#createRange `Writer#createRange()`}:
*
* ```ts
* model.change( writer => {
* const range = writer.createRange( start, end );
* } );
* ```
*
* @param start Start position.
* @param end End position. If not set, the range will be collapsed to the `start` position.
*/
public createRange( start: ModelPosition, end?: ModelPosition ): ModelRange {
return new ModelRange( start, end );
}
/**
* Creates a range inside the given element which starts before the first child of
* that element and ends after the last child of that element.
*
* Note: This method is also available as
* {@link module:engine/model/writer~Writer#createRangeIn `Writer#createRangeIn()`}:
*
* ```ts
* model.change( writer => {
* const range = writer.createRangeIn( paragraph );
* } );
* ```
*
* @param element Element which is a parent for the range.
*/
public createRangeIn( element: ModelElement | ModelDocumentFragment ): ModelRange {
return ModelRange._createIn( element );
}
/**
* Creates a range that starts before the given {@link module:engine/model/item~Item model item} and ends after it.
*
* Note: This method is also available on `writer` instance as
* {@link module:engine/model/writer~Writer#createRangeOn `Writer.createRangeOn()`}:
*
* ```ts
* model.change( writer => {
* const range = writer.createRangeOn( paragraph );
* } );
* ```
*
* @param item
*/
public createRangeOn( item: Item ): ModelRange {
return ModelRange._createOn( item );
}
/**
* Creates a new selection instance based on the given {@link module:engine/model/selection~Selectable selectable}
* or creates an empty selection if no arguments were passed.
*
* Note: This method is also available as
* {@link module:engine/model/writer~Writer#createSelection `Writer#createSelection()`}.
*
* ```ts
* // Creates selection at the given offset in the given element.
* const paragraph = writer.createElement( 'paragraph' );
* const selection = writer.createSelection( paragraph, offset );
*
* // Creates a range inside an {@link module:engine/model/element~Element element} which starts before the
* // first child of that element and ends after the last child of that element.
* const selection = writer.createSelection( paragraph, 'in' );
*
* // Creates a range on an {@link module:engine/model/item~Item item} which starts before the item and ends
* // just after the item.