/
converters.ts
632 lines (522 loc) · 18.7 KB
/
converters.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
/**
* @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/converters
*/
import {
UpcastWriter,
type DowncastAttributeEvent,
type DowncastWriter,
type EditingController,
type Element,
type ElementCreatorFunction,
type Mapper,
type Model,
type ModelConsumable,
type Node,
type UpcastElementEvent,
type ViewDocumentFragment,
type ViewElement,
type ViewRange
} from 'ckeditor5/src/engine';
import type { GetCallback } from 'ckeditor5/src/utils';
import {
getAllListItemBlocks,
getListItemBlocks,
isListItemBlock,
isFirstBlockOfListItem,
ListItemUid,
type ListElement
} from './utils/model';
import {
createListElement,
createListItemElement,
getIndent,
isListView,
isListItemView
} from './utils/view';
import ListWalker, { iterateSiblingListBlocks } from './utils/listwalker';
import { findAndAddListHeadToMap } from './utils/postfixers';
import type {
default as DocumentListEditing,
DocumentListEditingCheckAttributesEvent,
DocumentListEditingCheckElementEvent,
ListItemAttributesMap,
DowncastStrategy
} from './documentlistediting';
/**
* Returns the upcast converter for list items. It's supposed to work after the block converters (content inside list items) are converted.
*
* @internal
*/
export function listItemUpcastConverter(): GetCallback<UpcastElementEvent> {
return ( evt, data, conversionApi ) => {
const { writer, schema } = conversionApi;
if ( !data.modelRange ) {
return;
}
const items = Array.from( data.modelRange.getItems( { shallow: true } ) )
.filter( ( item ): item is Element => schema.checkAttribute( item, 'listItemId' ) );
if ( !items.length ) {
return;
}
const listItemId = ListItemUid.next();
const listIndent = getIndent( data.viewItem );
let listType = data.viewItem.parent && data.viewItem.parent.is( 'element', 'ol' ) ? 'numbered' : 'bulleted';
// Preserve list type if was already set (for example by to-do list feature).
const firstItemListType = items[ 0 ].getAttribute( 'listType' ) as string;
if ( firstItemListType ) {
listType = firstItemListType;
}
const attributes = {
listItemId,
listIndent,
listType
};
for ( const item of items ) {
// Set list attributes only on same level items, those nested deeper are already handled by the recursive conversion.
if ( !item.hasAttribute( 'listItemId' ) ) {
writer.setAttributes( attributes, item );
}
}
if ( items.length > 1 ) {
// Make sure that list item that contain only nested list will preserve paragraph for itself:
// <ul>
// <li>
// <p></p> <-- this one must be kept
// <ul>
// <li></li>
// </ul>
// </li>
// </ul>
if ( items[ 1 ].getAttribute( 'listItemId' ) != attributes.listItemId ) {
conversionApi.keepEmptyElement( items[ 0 ] );
}
}
};
}
/**
* Returns the upcast converter for the `<ul>` and `<ol>` view elements that cleans the input view of garbage.
* This is mostly to clean whitespaces from between the `<li>` view elements inside the view list element. However,
* incorrect data can also be cleared if the view was incorrect.
*
* @internal
*/
export function listUpcastCleanList(): GetCallback<UpcastElementEvent> {
return ( evt, data, conversionApi ) => {
if ( !conversionApi.consumable.test( data.viewItem, { name: true } ) ) {
return;
}
const viewWriter = new UpcastWriter( data.viewItem.document );
for ( const child of Array.from( data.viewItem.getChildren() ) ) {
if ( !isListItemView( child ) && !isListView( child ) ) {
viewWriter.remove( child );
}
}
};
}
/**
* Returns a model document change:data event listener that triggers conversion of related items if needed.
*
* @internal
* @param model The editor model.
* @param editing The editing controller.
* @param attributeNames The list of all model list attributes (including registered strategies).
* @param documentListEditing The document list editing plugin.
*/
export function reconvertItemsOnDataChange(
model: Model,
editing: EditingController,
attributeNames: Array<string>,
documentListEditing: DocumentListEditing
): () => void {
return () => {
const changes = model.document.differ.getChanges();
const itemsToRefresh = [];
const itemToListHead = new Map<ListElement, ListElement>();
const changedItems = new Set<Node>();
for ( const entry of changes ) {
if ( entry.type == 'insert' && entry.name != '$text' ) {
findAndAddListHeadToMap( entry.position, itemToListHead );
// Insert of a non-list item.
if ( !entry.attributes.has( 'listItemId' ) ) {
findAndAddListHeadToMap( entry.position.getShiftedBy( entry.length ), itemToListHead );
} else {
changedItems.add( entry.position.nodeAfter! );
}
}
// Removed list item.
else if ( entry.type == 'remove' && entry.attributes.has( 'listItemId' ) ) {
findAndAddListHeadToMap( entry.position, itemToListHead );
}
// Changed list attribute.
else if ( entry.type == 'attribute' ) {
const item = entry.range.start.nodeAfter!;
if ( attributeNames.includes( entry.attributeKey ) ) {
findAndAddListHeadToMap( entry.range.start, itemToListHead );
if ( entry.attributeNewValue === null ) {
findAndAddListHeadToMap( entry.range.start.getShiftedBy( 1 ), itemToListHead );
// Check if paragraph should be converted from bogus to plain paragraph.
if ( doesItemBlockRequiresRefresh( item as Element ) ) {
itemsToRefresh.push( item );
}
} else {
changedItems.add( item );
}
} else if ( isListItemBlock( item ) ) {
// Some other attribute was changed on the list item,
// check if paragraph does not need to be converted to bogus or back.
if ( doesItemBlockRequiresRefresh( item ) ) {
itemsToRefresh.push( item );
}
}
}
}
for ( const listHead of itemToListHead.values() ) {
itemsToRefresh.push( ...collectListItemsToRefresh( listHead, changedItems ) );
}
for ( const item of new Set( itemsToRefresh ) ) {
editing.reconvertItem( item );
}
};
function collectListItemsToRefresh( listHead: ListElement, changedItems: Set<Node> ) {
const itemsToRefresh = [];
const visited = new Set();
const stack: Array<ListItemAttributesMap> = [];
for ( const { node, previous } of iterateSiblingListBlocks( listHead, 'forward' ) ) {
if ( visited.has( node ) ) {
continue;
}
const itemIndent = node.getAttribute( 'listIndent' );
// Current node is at the lower indent so trim the stack.
if ( previous && itemIndent < previous.getAttribute( 'listIndent' ) ) {
stack.length = itemIndent + 1;
}
// Update the stack for the current indent level.
stack[ itemIndent ] = Object.fromEntries(
Array.from( node.getAttributes() )
.filter( ( [ key ] ) => attributeNames.includes( key ) )
);
// Find all blocks of the current node.
const blocks = getListItemBlocks( node, { direction: 'forward' } );
for ( const block of blocks ) {
visited.add( block );
// Check if bogus vs plain paragraph needs refresh.
if ( doesItemBlockRequiresRefresh( block, blocks ) ) {
itemsToRefresh.push( block );
}
// Check if wrapping with UL, OL, LIs needs refresh.
else if ( doesItemWrappingRequiresRefresh( block, stack, changedItems ) ) {
itemsToRefresh.push( block );
}
}
}
return itemsToRefresh;
}
function doesItemBlockRequiresRefresh( item: Element, blocks?: Array<Node> ) {
const viewElement = editing.mapper.toViewElement( item );
if ( !viewElement ) {
return false;
}
const needsRefresh = documentListEditing.fire<DocumentListEditingCheckElementEvent>( 'checkElement', {
modelElement: item,
viewElement
} );
if ( needsRefresh ) {
return true;
}
if ( !item.is( 'element', 'paragraph' ) && !item.is( 'element', 'listItem' ) ) {
return false;
}
const useBogus = shouldUseBogusParagraph( item, attributeNames, blocks );
if ( useBogus && viewElement.is( 'element', 'p' ) ) {
return true;
} else if ( !useBogus && viewElement.is( 'element', 'span' ) ) {
return true;
}
return false;
}
function doesItemWrappingRequiresRefresh(
item: Element,
stack: Array<ListItemAttributesMap>,
changedItems: Set<Node>
) {
// Items directly affected by some "change" don't need a refresh, they will be converted by their own changes.
if ( changedItems.has( item ) ) {
return false;
}
const viewElement = editing.mapper.toViewElement( item )!;
let indent = stack.length - 1;
// Traverse down the stack to the root to verify if all ULs, OLs, and LIs are as expected.
for (
let element = viewElement.parent!;
!element.is( 'editableElement' );
element = element.parent!
) {
const isListItemElement = isListItemView( element );
const isListElement = isListView( element );
if ( !isListElement && !isListItemElement ) {
continue;
}
const eventName = `checkAttributes:${ isListItemElement ? 'item' : 'list' }` as const;
const needsRefresh = documentListEditing.fire<DocumentListEditingCheckAttributesEvent>( eventName, {
viewElement: element as ViewElement,
modelAttributes: stack[ indent ]
} );
if ( needsRefresh ) {
break;
}
if ( isListElement ) {
indent--;
// Don't need to iterate further if we already know that the item is wrapped appropriately.
if ( indent < 0 ) {
return false;
}
}
}
return true;
}
}
/**
* Returns the list item downcast converter.
*
* @internal
* @param attributeNames A list of attribute names that should be converted if they are set.
* @param strategies The strategies.
* @param model The model.
*/
export function listItemDowncastConverter(
attributeNames: Array<string>,
strategies: Array<DowncastStrategy>,
model: Model,
{ dataPipeline }: { dataPipeline?: boolean } = {}
): GetCallback<DowncastAttributeEvent<ListElement>> {
const consumer = createAttributesConsumer( attributeNames );
return ( evt, data, conversionApi ) => {
const { writer, mapper, consumable } = conversionApi;
const listItem = data.item;
if ( !attributeNames.includes( data.attributeKey ) ) {
return;
}
// Test if attributes on the converted items are not consumed.
if ( !consumer( listItem, consumable ) ) {
return;
}
// Use positions mapping instead of mapper.toViewElement( listItem ) to find outermost view element.
// This is for cases when mapping is using inner view element like in the code blocks (pre > code).
const viewElement = findMappedViewElement( listItem, mapper, model )!;
// Remove custom item marker.
removeCustomMarkerElements( viewElement, writer, mapper );
// Unwrap element from current list wrappers.
unwrapListItemBlock( viewElement, writer );
// Insert custom item marker.
const viewRange = insertCustomMarkerElements( listItem, viewElement, strategies, writer, { dataPipeline } );
// Then wrap them with the new list wrappers (UL, OL, LI).
wrapListItemBlock( listItem, viewRange, strategies, writer );
};
}
/**
* Returns the bogus paragraph view element creator. A bogus paragraph is used if a list item contains only a single block or nested list.
*
* @internal
* @param attributeNames The list of all model list attributes (including registered strategies).
*/
export function bogusParagraphCreator(
attributeNames: Array<string>,
{ dataPipeline }: { dataPipeline?: boolean } = {}
): ElementCreatorFunction {
return ( modelElement, { writer } ) => {
// Convert only if a bogus paragraph should be used.
if ( !shouldUseBogusParagraph( modelElement, attributeNames ) ) {
return null;
}
if ( !dataPipeline ) {
return writer.createContainerElement( 'span', { class: 'ck-list-bogus-paragraph' } );
}
// Using `<p>` in case there are some markers on it and transparentRendering will render it anyway.
const viewElement = writer.createContainerElement( 'p' );
writer.setCustomProperty( 'dataPipeline:transparentRendering', true, viewElement );
return viewElement;
};
}
/**
* Helper for mapping mode to view elements. It's using positions mapping instead of mapper.toViewElement( element )
* to find outermost view element. This is for cases when mapping is using inner view element like in the code blocks (pre > code).
*
* @internal
* @param element The model element.
* @param mapper The mapper instance.
* @param model The model.
*/
export function findMappedViewElement( element: Element, mapper: Mapper, model: Model ): ViewElement | null {
const modelRange = model.createRangeOn( element );
const viewRange = mapper.toViewRange( modelRange ).getTrimmed();
return viewRange.end.nodeBefore as ViewElement | null;
}
/**
* Removes a custom marker elements and item wrappers related to that marker.
*/
function removeCustomMarkerElements( viewElement: ViewElement, viewWriter: DowncastWriter, mapper: Mapper ): void {
// Remove item wrapper.
while ( viewElement.parent!.is( 'attributeElement' ) && viewElement.parent!.getCustomProperty( 'listItemWrapper' ) ) {
viewWriter.unwrap( viewWriter.createRangeIn( viewElement.parent ), viewElement.parent );
}
// Remove custom item markers.
const viewWalker = viewWriter.createPositionBefore( viewElement ).getWalker( { direction: 'backward' } );
const markersToRemove = [];
for ( const { item } of viewWalker ) {
// Walk only over the non-mapped elements between list item blocks.
if ( item.is( 'element' ) && mapper.toModelElement( item ) ) {
break;
}
if ( item.is( 'element' ) && item.getCustomProperty( 'listItemMarker' ) ) {
markersToRemove.push( item );
}
}
for ( const marker of markersToRemove ) {
viewWriter.remove( marker );
}
}
/**
* Inserts a custom marker elements and wraps first block of a list item if marker requires it.
*/
function insertCustomMarkerElements(
listItem: Element,
viewElement: ViewElement,
strategies: Array<DowncastStrategy>,
writer: DowncastWriter,
{ dataPipeline }: { dataPipeline?: boolean }
): ViewRange {
let viewRange = writer.createRangeOn( viewElement );
// Marker can be inserted only before the first block of a list item.
if ( !isFirstBlockOfListItem( listItem ) ) {
return viewRange;
}
for ( const strategy of strategies ) {
if ( strategy.scope != 'itemMarker' ) {
continue;
}
// Create the custom marker element and inject it before the first block of the list item.
const markerElement = strategy.createElement( writer, listItem, { dataPipeline } );
if ( !markerElement ) {
continue;
}
writer.setCustomProperty( 'listItemMarker', true, markerElement );
writer.insert( viewRange.start, markerElement );
viewRange = writer.createRange(
writer.createPositionBefore( markerElement ),
writer.createPositionAfter( viewElement )
);
// Wrap the marker and optionally the first block with an attribute element (label for to-do lists).
if ( !strategy.createWrapperElement || !strategy.canWrapElement ) {
continue;
}
const wrapper = strategy.createWrapperElement( writer, listItem, { dataPipeline } );
writer.setCustomProperty( 'listItemWrapper', true, wrapper );
// The whole block can be wrapped...
if ( strategy.canWrapElement( listItem ) ) {
viewRange = writer.wrap( viewRange, wrapper );
} else {
// ... or only the marker element (if the block is downcasted to heading or block widget).
viewRange = writer.wrap( writer.createRangeOn( markerElement ), wrapper );
viewRange = writer.createRange(
viewRange.start,
writer.createPositionAfter( viewElement )
);
}
}
return viewRange;
}
/**
* Unwraps all ol, ul, and li attribute elements that are wrapping the provided view element.
*/
function unwrapListItemBlock( viewElement: ViewElement, viewWriter: DowncastWriter ) {
let attributeElement: ViewElement | ViewDocumentFragment = viewElement.parent!;
while ( attributeElement.is( 'attributeElement' ) && [ 'ul', 'ol', 'li' ].includes( attributeElement.name ) ) {
const parentElement = attributeElement.parent;
viewWriter.unwrap( viewWriter.createRangeOn( viewElement ), attributeElement );
attributeElement = parentElement!;
}
}
/**
* Wraps the given list item with appropriate attribute elements for ul, ol, and li.
*/
function wrapListItemBlock(
listItem: ListElement,
viewRange: ViewRange,
strategies: Array<DowncastStrategy>,
writer: DowncastWriter
) {
if ( !listItem.hasAttribute( 'listIndent' ) ) {
return;
}
const listItemIndent = listItem.getAttribute( 'listIndent' );
let currentListItem: ListElement | null = listItem;
for ( let indent = listItemIndent; indent >= 0; indent-- ) {
const listItemViewElement = createListItemElement( writer, indent, currentListItem.getAttribute( 'listItemId' ) );
const listViewElement = createListElement( writer, indent, currentListItem.getAttribute( 'listType' ) );
for ( const strategy of strategies ) {
if (
( strategy.scope == 'list' || strategy.scope == 'item' ) &&
currentListItem.hasAttribute( strategy.attributeName )
) {
strategy.setAttributeOnDowncast(
writer,
currentListItem.getAttribute( strategy.attributeName ),
strategy.scope == 'list' ? listViewElement : listItemViewElement
);
}
}
viewRange = writer.wrap( viewRange, listItemViewElement );
viewRange = writer.wrap( viewRange, listViewElement );
if ( indent == 0 ) {
break;
}
currentListItem = ListWalker.first( currentListItem, { lowerIndent: true } );
// There is no list item with lower indent, this means this is a document fragment containing
// only a part of nested list (like copy to clipboard) so we don't need to try to wrap it further.
if ( !currentListItem ) {
break;
}
}
}
// Returns the function that is responsible for consuming attributes that are set on the model node.
function createAttributesConsumer( attributeNames: Array<string> ) {
return ( node: Node, consumable: ModelConsumable ) => {
const events = [];
// Collect all set attributes that are triggering conversion.
for ( const attributeName of attributeNames ) {
if ( node.hasAttribute( attributeName ) ) {
events.push( `attribute:${ attributeName }` );
}
}
if ( !events.every( event => consumable.test( node, event ) !== false ) ) {
return false;
}
events.forEach( event => consumable.consume( node, event ) );
return true;
};
}
// Whether the given item should be rendered as a bogus paragraph.
function shouldUseBogusParagraph(
item: Node,
attributeNames: Array<string>,
blocks: Array<Node> = getAllListItemBlocks( item )
) {
if ( !isListItemBlock( item ) ) {
return false;
}
for ( const attributeKey of item.getAttributeKeys() ) {
// Ignore selection attributes stored on block elements.
if ( attributeKey.startsWith( 'selection:' ) ) {
continue;
}
// Don't use bogus paragraph if there are attributes from other features.
if ( !attributeNames.includes( attributeKey ) ) {
return false;
}
}
return blocks.length < 2;
}