/
model.js
534 lines (458 loc) · 16.7 KB
/
model.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
/**
* @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module list/documentlist/utils/model
*/
import { uid, toArray } from 'ckeditor5/src/utils';
import ListWalker, { iterateSiblingListBlocks } from './listwalker';
/**
* The list item ID generator.
*
* @protected
*/
export class ListItemUid {
/**
* Returns the next ID.
*
* @protected
* @returns {String}
*/
/* istanbul ignore next: static function definition */
static next() {
return uid();
}
}
/**
* Returns true if the given model node is a list item block.
*
* @protected
* @param {module:engine/model/node~Node} node A model node.
* @returns {Boolean}
*/
export function isListItemBlock( node ) {
return !!node && node.is( 'element' ) && node.hasAttribute( 'listItemId' );
}
/**
* Returns an array with all elements that represents the same list item.
*
* It means that values for `listIndent`, and `listItemId` for all items are equal.
*
* @protected
* @param {module:engine/model/element~Element} listItem Starting list item element.
* @param {Object} [options]
* @param {Boolean} [options.higherIndent=false] Whether blocks with a higher indent level than the start block should be included
* in the result.
* @return {Array.<module:engine/model/element~Element>}
*/
export function getAllListItemBlocks( listItem, options = {} ) {
return [
...getListItemBlocks( listItem, { ...options, direction: 'backward' } ),
...getListItemBlocks( listItem, { ...options, direction: 'forward' } )
];
}
/**
* Returns an array with elements that represents the same list item in the specified direction.
*
* It means that values for `listIndent` and `listItemId` for all items are equal.
*
* **Note**: For backward search the provided item is not included, but for forward search it is included in the result.
*
* @protected
* @param {module:engine/model/element~Element} listItem Starting list item element.
* @param {Object} [options]
* @param {'forward'|'backward'} [options.direction='backward'] Walking direction.
* @param {Boolean} [options.higherIndent=false] Whether blocks with a higher indent level than the start block should be included
* in the result.
* @returns {Array.<module:engine/model/element~Element>}
*/
export function getListItemBlocks( listItem, options = {} ) {
const isForward = options.direction == 'forward';
const items = Array.from( new ListWalker( listItem, {
...options,
includeSelf: isForward,
sameIndent: true,
sameAttributes: 'listItemId'
} ) );
return isForward ? items : items.reverse();
}
/**
* Returns a list items nested inside the given list item.
*
* @protected
* @param {module:engine/model/element~Element} listItem Starting list item element.
* @returns {Array.<module:engine/model/element~Element>}
*/
export function getNestedListBlocks( listItem ) {
return Array.from( new ListWalker( listItem, {
direction: 'forward',
higherIndent: true
} ) );
}
/**
* Returns array of all blocks/items of the same list as given block (same indent, same type and properties).
*
* @protected
* @param {module:engine/model/element~Element} listItem Starting list item element.
* @returns {Array.<module:engine/model/element~Element>}
*/
export function getListItems( listItem ) {
const backwardBlocks = new ListWalker( listItem, {
sameIndent: true,
sameAttributes: 'listType'
} );
const forwardBlocks = new ListWalker( listItem, {
sameIndent: true,
sameAttributes: 'listType',
includeSelf: true,
direction: 'forward'
} );
return [
...Array.from( backwardBlocks ).reverse(),
...forwardBlocks
];
}
/**
* Check if the given block is the first in the list item.
*
* @protected
* @param {module:engine/model/element~Element} listBlock The list block element.
* @returns {Boolean}
*/
export function isFirstBlockOfListItem( listBlock ) {
const previousSibling = ListWalker.first( listBlock, {
sameIndent: true,
sameAttributes: 'listItemId'
} );
if ( !previousSibling ) {
return true;
}
return false;
}
/**
* Check if the given block is the last in the list item.
*
* @protected
* @param {module:engine/model/element~Element} listBlock The list block element.
* @returns {Boolean}
*/
export function isLastBlockOfListItem( listBlock ) {
const nextSibling = ListWalker.first( listBlock, {
direction: 'forward',
sameIndent: true,
sameAttributes: 'listItemId'
} );
if ( !nextSibling ) {
return true;
}
return false;
}
/**
* Expands the given list of selected blocks to include the leading and tailing blocks of partially selected list items.
*
* @protected
* @param {module:engine/model/element~Element|Array.<module:engine/model/element~Element>} blocks The list of selected blocks.
* @param {Object} [options]
* @param {Boolean} [options.withNested=true] Whether should include nested list items.
* @returns {Array.<module:engine/model/element~Element>}
*/
export function expandListBlocksToCompleteItems( blocks, options = {} ) {
blocks = toArray( blocks );
const higherIndent = options.withNested !== false;
const allBlocks = new Set();
for ( const block of blocks ) {
for ( const itemBlock of getAllListItemBlocks( block, { higherIndent } ) ) {
allBlocks.add( itemBlock );
}
}
return sortBlocks( allBlocks );
}
/**
* Expands the given list of selected blocks to include all the items of the lists they're in.
*
* @protected
* @param {module:engine/model/element~Element|Array.<module:engine/model/element~Element>} blocks The list of selected blocks.
* @returns {Array.<module:engine/model/element~Element>}
*/
export function expandListBlocksToCompleteList( blocks ) {
blocks = toArray( blocks );
const allBlocks = new Set();
for ( const block of blocks ) {
for ( const itemBlock of getListItems( block ) ) {
allBlocks.add( itemBlock );
}
}
return sortBlocks( allBlocks );
}
/**
* Splits the list item just before the provided list block.
*
* @protected
* @param {module:engine/model/element~Element} listBlock The list block element.
* @param {module:engine/model/writer~Writer} writer The model writer.
* @returns {Array.<module:engine/model/element~Element>} The array of updated blocks.
*/
export function splitListItemBefore( listBlock, writer ) {
const blocks = getListItemBlocks( listBlock, { direction: 'forward' } );
const id = ListItemUid.next();
for ( const block of blocks ) {
writer.setAttribute( 'listItemId', id, block );
}
return blocks;
}
/**
* Merges the list item with the parent list item.
*
* @protected
* @param {module:engine/model/element~Element} listBlock The list block element.
* @param {module:engine/model/element~Element} parentBlock The list block element to merge with.
* @param {module:engine/model/writer~Writer} writer The model writer.
* @returns {Array.<module:engine/model/element~Element>} The array of updated blocks.
*/
export function mergeListItemBefore( listBlock, parentBlock, writer ) {
const attributes = {};
for ( const [ key, value ] of parentBlock.getAttributes() ) {
if ( key.startsWith( 'list' ) ) {
attributes[ key ] = value;
}
}
const blocks = getListItemBlocks( listBlock, { direction: 'forward' } );
for ( const block of blocks ) {
writer.setAttributes( attributes, block );
}
return blocks;
}
/**
* Increases indentation of given list blocks.
*
* @protected
* @param {module:engine/model/element~Element|Iterable.<module:engine/model/element~Element>} blocks The block or iterable of blocks.
* @param {module:engine/model/writer~Writer} writer The model writer.
* @param {Object} [options]
* @param {Boolean} [options.expand=false] Whether should expand the list of blocks to include complete list items.
* @param {Number} [options.indentBy=1] The number of levels the indentation should change (could be negative).
*/
export function indentBlocks( blocks, writer, { expand, indentBy = 1 } = {} ) {
blocks = toArray( blocks );
// Expand the selected blocks to contain the whole list items.
const allBlocks = expand ? expandListBlocksToCompleteItems( blocks ) : blocks;
for ( const block of allBlocks ) {
const blockIndent = block.getAttribute( 'listIndent' ) + indentBy;
if ( blockIndent < 0 ) {
removeListAttributes( block, writer );
} else {
writer.setAttribute( 'listIndent', blockIndent, block );
}
}
return allBlocks;
}
/**
* Decreases indentation of given list of blocks. If the indentation of some blocks matches the indentation
* of surrounding blocks, they get merged together.
*
* @protected
* @param {module:engine/model/element~Element|Iterable.<module:engine/model/element~Element>} blocks The block or iterable of blocks.
* @param {module:engine/model/writer~Writer} writer The model writer.
*/
export function outdentBlocksWithMerge( blocks, writer ) {
blocks = toArray( blocks );
// Expand the selected blocks to contain the whole list items.
const allBlocks = expandListBlocksToCompleteItems( blocks );
const visited = new Set();
const referenceIndent = Math.min( ...allBlocks.map( block => block.getAttribute( 'listIndent' ) ) );
const parentBlocks = new Map();
// Collect parent blocks before the list structure gets altered.
for ( const block of allBlocks ) {
parentBlocks.set( block, ListWalker.first( block, { lowerIndent: true } ) );
}
for ( const block of allBlocks ) {
if ( visited.has( block ) ) {
continue;
}
visited.add( block );
const blockIndent = block.getAttribute( 'listIndent' ) - 1;
if ( blockIndent < 0 ) {
removeListAttributes( block, writer );
continue;
}
// Merge with parent list item while outdenting and indent matches reference indent.
if ( block.getAttribute( 'listIndent' ) == referenceIndent ) {
const mergedBlocks = mergeListItemIfNotLast( block, parentBlocks.get( block ), writer );
// All list item blocks are updated while merging so add those to visited set.
for ( const mergedBlock of mergedBlocks ) {
visited.add( mergedBlock );
}
// The indent level was updated while merging so continue to next block.
if ( mergedBlocks.length ) {
continue;
}
}
writer.setAttribute( 'listIndent', blockIndent, block );
}
return sortBlocks( visited );
}
/**
* Removes all list attributes from the given blocks.
*
* @protected
* @param {module:engine/model/element~Element|Iterable.<module:engine/model/element~Element>} blocks The block or iterable of blocks.
* @param {module:engine/model/writer~Writer} writer The model writer.
* @returns {Array.<module:engine/model/element~Element>} Array of altered blocks.
*/
export function removeListAttributes( blocks, writer ) {
blocks = toArray( blocks );
for ( const block of blocks ) {
for ( const attributeKey of block.getAttributeKeys() ) {
if ( attributeKey.startsWith( 'list' ) ) {
writer.removeAttribute( attributeKey, block );
}
}
}
return blocks;
}
/**
* Checks whether the given blocks are related to a single list item.
*
* @protected
* @param {Array.<module:engine/model/element~Element>} blocks The list block elements.
* @returns {Boolean}
*/
export function isSingleListItem( blocks ) {
if ( !blocks.length ) {
return false;
}
const firstItemId = blocks[ 0 ].getAttribute( 'listItemId' );
if ( !firstItemId ) {
return false;
}
return !blocks.some( item => item.getAttribute( 'listItemId' ) != firstItemId );
}
/**
* Modifies the indents of list blocks following the given list block so the indentation is valid after
* the given block is no longer a list item.
*
* @protected
* @param {module:engine/model/element~Element} lastBlock The last list block that has become a non-list element.
* @param {module:engine/model/writer~Writer} writer The model writer.
* @returns {Array.<module:engine/model/element~Element>} Array of altered blocks.
*/
export function outdentFollowingItems( lastBlock, writer ) {
const changedBlocks = [];
// Start from the model item that is just after the last turned-off item.
let currentIndent = Number.POSITIVE_INFINITY;
// Correct indent of all items after the last turned off item.
// Rules that should be followed:
// 1. All direct sub-items of turned-off item should become indent 0, because the first item after it
// will be the first item of a new list. Other items are at the same level, so should have same 0 index.
// 2. All items with indent lower than indent of turned-off item should become indent 0, because they
// should not end up as a child of any of list items that they were not children of before.
// 3. All other items should have their indent changed relatively to it's parent.
//
// For example:
// 1 * --------
// 2 * --------
// 3 * -------- <-- this is turned off.
// 4 * -------- <-- this has to become indent = 0, because it will be first item on a new list.
// 5 * -------- <-- this should be still be a child of item above, so indent = 1.
// 6 * -------- <-- this has to become indent = 0, because it should not be a child of any of items above.
// 7 * -------- <-- this should be still be a child of item above, so indent = 1.
// 8 * -------- <-- this has to become indent = 0.
// 9 * -------- <-- this should still be a child of item above, so indent = 1.
// 10 * -------- <-- this should still be a child of item above, so indent = 2.
// 11 * -------- <-- this should still be at the same level as item above, so indent = 2.
// 12 * -------- <-- this and all below are left unchanged.
// 13 * --------
// 14 * --------
//
// After turning off 3 the list becomes:
//
// 1 * --------
// 2 * --------
//
// 3 --------
//
// 4 * --------
// 5 * --------
// 6 * --------
// 7 * --------
// 8 * --------
// 9 * --------
// 10 * --------
// 11 * --------
// 12 * --------
// 13 * --------
// 14 * --------
//
// Thanks to this algorithm no lists are mismatched and no items get unexpected children/parent, while
// those parent-child connection which are possible to maintain are still maintained. It's worth noting
// that this is the same effect that we would be get by multiple use of outdent command. However doing
// it like this is much more efficient because it's less operation (less memory usage, easier OT) and
// less conversion (faster).
for ( const { node } of iterateSiblingListBlocks( lastBlock.nextSibling, 'forward' ) ) {
// Check each next list item, as long as its indent is higher than 0.
const indent = node.getAttribute( 'listIndent' );
// If the indent is 0 we are not going to change anything anyway.
if ( indent == 0 ) {
break;
}
// We check if that's item indent is lower than current relative indent.
if ( indent < currentIndent ) {
// If it is, current relative indent becomes that indent.
currentIndent = indent;
}
// Fix indent relatively to current relative indent.
// Note, that if we just changed the current relative indent, the newIndent will be equal to 0.
const newIndent = indent - currentIndent;
writer.setAttribute( 'listIndent', newIndent, node );
changedBlocks.push( node );
}
return changedBlocks;
}
/**
* Returns the array of given blocks sorted by model indexes (document order).
*
* @protected
* @param {Iterable.<module:engine/model/element~Element>} blocks The array of blocks.
* @returns {Array.<module:engine/model/element~Element>} The sorted array of blocks.
*/
export function sortBlocks( blocks ) {
return Array.from( blocks )
.filter( block => block.root.rootName !== '$graveyard' )
.sort( ( a, b ) => a.index - b.index );
}
/**
* Returns a selected block object. If a selected object is inline or when there is no selected
* object, `null` is returned.
*
* @protected
* @param {module:engine/model/model~Model} model The instance of editor model.
* @returns {module:engine/model/element~Element|null} Selected block object or `null`.
*/
export function getSelectedBlockObject( model ) {
const selectedElement = model.document.selection.getSelectedElement();
if ( !selectedElement ) {
return null;
}
if ( model.schema.isObject( selectedElement ) && model.schema.isBlock( selectedElement ) ) {
return selectedElement;
}
return null;
}
// Merges a given block to the given parent block if parent is a list item and there is no more blocks in the same item.
function mergeListItemIfNotLast( block, parentBlock, writer ) {
const parentItemBlocks = getListItemBlocks( parentBlock, { direction: 'forward' } );
// Merge with parent only if outdented item wasn't the last one in its parent.
// Merge:
// * a -> * a
// * [b] -> b
// c -> c
// Don't merge:
// * a -> * a
// * [b] -> * b
// * c -> * c
if ( parentItemBlocks.pop().index > block.index ) {
return mergeListItemBefore( block, parentBlock, writer );
}
return [];
}