UNPKG

41.1 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
3 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4 */
5
6/**
7 * @module list/list/converters
8 */
9
10import { TreeWalker } from 'ckeditor5/src/engine';
11
12import {
13 generateLiInUl,
14 injectViewList,
15 mergeViewLists,
16 getSiblingListItem,
17 positionAfterUiElements
18} from './utils';
19
20/**
21 * A model-to-view converter for the `listItem` model element insertion.
22 *
23 * It creates a `<ul><li></li><ul>` (or `<ol>`) view structure out of a `listItem` model element, inserts it at the correct
24 * position, and merges the list with surrounding lists (if available).
25 *
26 * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert
27 * @param {module:engine/model/model~Model} model Model instance.
28 * @returns {Function} Returns a conversion callback.
29 */
30export function modelViewInsertion( model ) {
31 return ( evt, data, conversionApi ) => {
32 const consumable = conversionApi.consumable;
33
34 if ( !consumable.test( data.item, 'insert' ) ||
35 !consumable.test( data.item, 'attribute:listType' ) ||
36 !consumable.test( data.item, 'attribute:listIndent' )
37 ) {
38 return;
39 }
40
41 consumable.consume( data.item, 'insert' );
42 consumable.consume( data.item, 'attribute:listType' );
43 consumable.consume( data.item, 'attribute:listIndent' );
44
45 const modelItem = data.item;
46 const viewItem = generateLiInUl( modelItem, conversionApi );
47
48 injectViewList( modelItem, viewItem, conversionApi, model );
49 };
50}
51
52/**
53 * A model-to-view converter for the `listItem` model element removal.
54 *
55 * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:remove
56 * @param {module:engine/model/model~Model} model Model instance.
57 * @returns {Function} Returns a conversion callback.
58 */
59export function modelViewRemove( model ) {
60 return ( evt, data, conversionApi ) => {
61 const viewPosition = conversionApi.mapper.toViewPosition( data.position );
62 const viewStart = viewPosition.getLastMatchingPosition( value => !value.item.is( 'element', 'li' ) );
63 const viewItem = viewStart.nodeAfter;
64 const viewWriter = conversionApi.writer;
65
66 // 1. Break the container after and before the list item.
67 // This will create a view list with one view list item - the one to remove.
68 viewWriter.breakContainer( viewWriter.createPositionBefore( viewItem ) );
69 viewWriter.breakContainer( viewWriter.createPositionAfter( viewItem ) );
70
71 // 2. Remove the list with the item to remove.
72 const viewList = viewItem.parent;
73 const viewListPrev = viewList.previousSibling;
74 const removeRange = viewWriter.createRangeOn( viewList );
75 const removed = viewWriter.remove( removeRange );
76
77 // 3. Merge the whole created by breaking and removing the list.
78 if ( viewListPrev && viewListPrev.nextSibling ) {
79 mergeViewLists( viewWriter, viewListPrev, viewListPrev.nextSibling );
80 }
81
82 // 4. Bring back nested list that was in the removed <li>.
83 const modelItem = conversionApi.mapper.toModelElement( viewItem );
84
85 hoistNestedLists( modelItem.getAttribute( 'listIndent' ) + 1, data.position, removeRange.start, viewItem, conversionApi, model );
86
87 // 5. Unbind removed view item and all children.
88 for ( const child of viewWriter.createRangeIn( removed ).getItems() ) {
89 conversionApi.mapper.unbindViewElement( child );
90 }
91
92 evt.stop();
93 };
94}
95
96/**
97 * A model-to-view converter for the `type` attribute change on the `listItem` model element.
98 *
99 * This change means that the `<li>` element parent changes from `<ul>` to `<ol>` (or vice versa). This is accomplished
100 * by breaking view elements and changing their name. The next {@link module:list/list/converters~modelViewMergeAfterChangeType}
101 * converter will attempt to merge split nodes.
102 *
103 * Splitting this conversion into 2 steps makes it possible to add an additional conversion in the middle.
104 * Check {@link module:list/todolist/todolistconverters~modelViewChangeType} to see an example of it.
105 *
106 * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
107 * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
108 * @param {Object} data Additional information about the change.
109 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface.
110 */
111export function modelViewChangeType( evt, data, conversionApi ) {
112 if ( !conversionApi.consumable.test( data.item, evt.name ) ) {
113 return;
114 }
115
116 const viewItem = conversionApi.mapper.toViewElement( data.item );
117 const viewWriter = conversionApi.writer;
118
119 // Break the container after and before the list item.
120 // This will create a view list with one view list item -- the one that changed type.
121 viewWriter.breakContainer( viewWriter.createPositionBefore( viewItem ) );
122 viewWriter.breakContainer( viewWriter.createPositionAfter( viewItem ) );
123
124 // Change name of the view list that holds the changed view item.
125 // We cannot just change name property, because that would not render properly.
126 const viewList = viewItem.parent;
127 const listName = data.attributeNewValue == 'numbered' ? 'ol' : 'ul';
128
129 viewWriter.rename( listName, viewList );
130}
131
132/**
133 * A model-to-view converter that attempts to merge nodes split by {@link module:list/list/converters~modelViewChangeType}.
134 *
135 * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
136 * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
137 * @param {Object} data Additional information about the change.
138 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface.
139 */
140export function modelViewMergeAfterChangeType( evt, data, conversionApi ) {
141 conversionApi.consumable.consume( data.item, evt.name );
142
143 const viewItem = conversionApi.mapper.toViewElement( data.item );
144 const viewList = viewItem.parent;
145 const viewWriter = conversionApi.writer;
146
147 // Merge the changed view list with other lists, if possible.
148 mergeViewLists( viewWriter, viewList, viewList.nextSibling );
149 mergeViewLists( viewWriter, viewList.previousSibling, viewList );
150}
151
152/**
153 * A model-to-view converter for the `listIndent` attribute change on the `listItem` model element.
154 *
155 * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
156 * @param {module:engine/model/model~Model} model Model instance.
157 * @returns {Function} Returns a conversion callback.
158 */
159export function modelViewChangeIndent( model ) {
160 return ( evt, data, conversionApi ) => {
161 if ( !conversionApi.consumable.consume( data.item, 'attribute:listIndent' ) ) {
162 return;
163 }
164
165 const viewItem = conversionApi.mapper.toViewElement( data.item );
166 const viewWriter = conversionApi.writer;
167
168 // 1. Break the container after and before the list item.
169 // This will create a view list with one view list item -- the one that changed type.
170 viewWriter.breakContainer( viewWriter.createPositionBefore( viewItem ) );
171 viewWriter.breakContainer( viewWriter.createPositionAfter( viewItem ) );
172
173 // 2. Extract view list with changed view list item and merge "hole" possibly created by breaking and removing elements.
174 const viewList = viewItem.parent;
175 const viewListPrev = viewList.previousSibling;
176 const removeRange = viewWriter.createRangeOn( viewList );
177 viewWriter.remove( removeRange );
178
179 if ( viewListPrev && viewListPrev.nextSibling ) {
180 mergeViewLists( viewWriter, viewListPrev, viewListPrev.nextSibling );
181 }
182
183 // 3. Bring back nested list that was in the removed <li>.
184 hoistNestedLists( data.attributeOldValue + 1, data.range.start, removeRange.start, viewItem, conversionApi, model );
185
186 // 4. Inject view list like it is newly inserted.
187 injectViewList( data.item, viewItem, conversionApi, model );
188
189 // 5. Consume insertion of children inside the item. They are already handled by re-building the item in view.
190 for ( const child of data.item.getChildren() ) {
191 conversionApi.consumable.consume( child, 'insert' );
192 }
193 };
194}
195
196/**
197 * A special model-to-view converter introduced by the {@link module:list/list~List list feature}. This converter is fired for
198 * insert change of every model item, and should be fired before the actual converter. The converter checks whether the inserted
199 * model item is a non-`listItem` element. If it is, and it is inserted inside a view list, the converter breaks the
200 * list so the model element is inserted to the view parent element corresponding to its model parent element.
201 *
202 * The converter prevents such situations:
203 *
204 * // Model: // View:
205 * <listItem>foo</listItem> <ul>
206 * <listItem>bar</listItem> <li>foo</li>
207 * <li>bar</li>
208 * </ul>
209 *
210 * // After change: // Correct view guaranteed by this converter:
211 * <listItem>foo</listItem> <ul><li>foo</li></ul><p>xxx</p><ul><li>bar</li></ul>
212 * <paragraph>xxx</paragraph> // Instead of this wrong view state:
213 * <listItem>bar</listItem> <ul><li>foo</li><p>xxx</p><li>bar</li></ul>
214 *
215 * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert
216 * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
217 * @param {Object} data Additional information about the change.
218 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface.
219 */
220export function modelViewSplitOnInsert( evt, data, conversionApi ) {
221 if ( !conversionApi.consumable.test( data.item, evt.name ) ) {
222 return;
223 }
224
225 if ( data.item.name != 'listItem' ) {
226 let viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
227
228 const viewWriter = conversionApi.writer;
229 const lists = [];
230
231 // Break multiple ULs/OLs if there are.
232 //
233 // Imagine following list:
234 //
235 // 1 --------
236 // 1.1 --------
237 // 1.1.1 --------
238 // 1.1.2 --------
239 // 1.1.3 --------
240 // 1.1.3.1 --------
241 // 1.2 --------
242 // 1.2.1 --------
243 // 2 --------
244 //
245 // Insert paragraph after item 1.1.1:
246 //
247 // 1 --------
248 // 1.1 --------
249 // 1.1.1 --------
250 //
251 // Lorem ipsum.
252 //
253 // 1.1.2 --------
254 // 1.1.3 --------
255 // 1.1.3.1 --------
256 // 1.2 --------
257 // 1.2.1 --------
258 // 2 --------
259 //
260 // In this case 1.1.2 has to become beginning of a new list.
261 // We need to break list before 1.1.2 (obvious), then we need to break list also before 1.2.
262 // Then we need to move those broken pieces one after another and merge:
263 //
264 // 1 --------
265 // 1.1 --------
266 // 1.1.1 --------
267 //
268 // Lorem ipsum.
269 //
270 // 1.1.2 --------
271 // 1.1.3 --------
272 // 1.1.3.1 --------
273 // 1.2 --------
274 // 1.2.1 --------
275 // 2 --------
276 //
277 while ( viewPosition.parent.name == 'ul' || viewPosition.parent.name == 'ol' ) {
278 viewPosition = viewWriter.breakContainer( viewPosition );
279
280 if ( viewPosition.parent.name != 'li' ) {
281 break;
282 }
283
284 // Remove lists that are after inserted element.
285 // They will be brought back later, below the inserted element.
286 const removeStart = viewPosition;
287 const removeEnd = viewWriter.createPositionAt( viewPosition.parent, 'end' );
288
289 // Don't remove if there is nothing to remove.
290 if ( !removeStart.isEqual( removeEnd ) ) {
291 const removed = viewWriter.remove( viewWriter.createRange( removeStart, removeEnd ) );
292 lists.push( removed );
293 }
294
295 viewPosition = viewWriter.createPositionAfter( viewPosition.parent );
296 }
297
298 // Bring back removed lists.
299 if ( lists.length > 0 ) {
300 for ( let i = 0; i < lists.length; i++ ) {
301 const previousList = viewPosition.nodeBefore;
302 const insertedRange = viewWriter.insert( viewPosition, lists[ i ] );
303 viewPosition = insertedRange.end;
304
305 // Don't merge first list! We want a split in that place (this is why this converter is introduced).
306 if ( i > 0 ) {
307 const mergePos = mergeViewLists( viewWriter, previousList, previousList.nextSibling );
308
309 // If `mergePos` is in `previousList` it means that the lists got merged.
310 // In this case, we need to fix insert position.
311 if ( mergePos && mergePos.parent == previousList ) {
312 viewPosition.offset--;
313 }
314 }
315 }
316
317 // Merge last inserted list with element after it.
318 mergeViewLists( viewWriter, viewPosition.nodeBefore, viewPosition.nodeAfter );
319 }
320 }
321}
322
323/**
324 * A special model-to-view converter introduced by the {@link module:list/list~List list feature}. This converter takes care of
325 * merging view lists after something is removed or moved from near them.
326 *
327 * Example:
328 *
329 * // Model: // View:
330 * <listItem>foo</listItem> <ul><li>foo</li></ul>
331 * <paragraph>xxx</paragraph> <p>xxx</p>
332 * <listItem>bar</listItem> <ul><li>bar</li></ul>
333 *
334 * // After change: // Correct view guaranteed by this converter:
335 * <listItem>foo</listItem> <ul>
336 * <listItem>bar</listItem> <li>foo</li>
337 * <li>bar</li>
338 * </ul>
339 *
340 * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:remove
341 * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
342 * @param {Object} data Additional information about the change.
343 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface.
344 */
345export function modelViewMergeAfter( evt, data, conversionApi ) {
346 const viewPosition = conversionApi.mapper.toViewPosition( data.position );
347 const viewItemPrev = viewPosition.nodeBefore;
348 const viewItemNext = viewPosition.nodeAfter;
349
350 // Merge lists if something (remove, move) was done from inside of list.
351 // Merging will be done only if both items are view lists of the same type.
352 // The check is done inside the helper function.
353 mergeViewLists( conversionApi.writer, viewItemPrev, viewItemNext );
354}
355
356/**
357 * A view-to-model converter that converts the `<li>` view elements into the `listItem` model elements.
358 *
359 * To set correct values of the `listType` and `listIndent` attributes the converter:
360 * * checks `<li>`'s parent,
361 * * stores and increases the `conversionApi.store.indent` value when `<li>`'s sub-items are converted.
362 *
363 * @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
364 * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
365 * @param {Object} data An object containing conversion input and a placeholder for conversion output and possibly other values.
366 * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface to be used by the callback.
367 */
368export function viewModelConverter( evt, data, conversionApi ) {
369 if ( conversionApi.consumable.consume( data.viewItem, { name: true } ) ) {
370 const writer = conversionApi.writer;
371
372 // 1. Create `listItem` model element.
373 const listItem = writer.createElement( 'listItem' );
374
375 // 2. Handle `listItem` model element attributes.
376 const indent = getIndent( data.viewItem );
377
378 writer.setAttribute( 'listIndent', indent, listItem );
379
380 // Set 'bulleted' as default. If this item is pasted into a context,
381 const type = data.viewItem.parent && data.viewItem.parent.name == 'ol' ? 'numbered' : 'bulleted';
382 writer.setAttribute( 'listType', type, listItem );
383
384 if ( !conversionApi.safeInsert( listItem, data.modelCursor ) ) {
385 return;
386 }
387
388 const nextPosition = viewToModelListItemChildrenConverter( listItem, data.viewItem.getChildren(), conversionApi );
389
390 // Result range starts before the first item and ends after the last.
391 data.modelRange = writer.createRange( data.modelCursor, nextPosition );
392
393 conversionApi.updateConversionResult( listItem, data );
394 }
395}
396
397/**
398 * A view-to-model converter for the `<ul>` and `<ol>` view elements that cleans the input view of garbage.
399 * This is mostly to clean whitespaces from between the `<li>` view elements inside the view list element, however, also
400 * incorrect data can be cleared if the view was incorrect.
401 *
402 * @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
403 * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
404 * @param {Object} data An object containing conversion input and a placeholder for conversion output and possibly other values.
405 * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface to be used by the callback.
406 */
407export function cleanList( evt, data, conversionApi ) {
408 if ( conversionApi.consumable.test( data.viewItem, { name: true } ) ) {
409 // Caching children because when we start removing them iterating fails.
410 const children = Array.from( data.viewItem.getChildren() );
411
412 for ( const child of children ) {
413 const isWrongElement = !( child.is( 'element', 'li' ) || isList( child ) );
414
415 if ( isWrongElement ) {
416 child._remove();
417 }
418 }
419 }
420}
421
422/**
423 * A view-to-model converter for the `<li>` elements that cleans whitespace formatting from the input view.
424 *
425 * @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
426 * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
427 * @param {Object} data An object containing conversion input and a placeholder for conversion output and possibly other values.
428 * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface to be used by the callback.
429 */
430export function cleanListItem( evt, data, conversionApi ) {
431 if ( conversionApi.consumable.test( data.viewItem, { name: true } ) ) {
432 if ( data.viewItem.childCount === 0 ) {
433 return;
434 }
435
436 const children = [ ...data.viewItem.getChildren() ];
437
438 let foundList = false;
439
440 for ( const child of children ) {
441 if ( foundList && !isList( child ) ) {
442 child._remove();
443 }
444
445 if ( isList( child ) ) {
446 // If this is a <ul> or <ol>, do not process it, just mark that we already visited list element.
447 foundList = true;
448 }
449 }
450 }
451}
452
453/**
454 * Returns a callback for model position to view position mapping for {@link module:engine/conversion/mapper~Mapper}. The callback fixes
455 * positions between the `listItem` elements that would be incorrectly mapped because of how list items are represented in the model
456 * and in the view.
457 *
458 * @see module:engine/conversion/mapper~Mapper#event:modelToViewPosition
459 * @param {module:engine/view/view~View} view A view instance.
460 * @returns {Function}
461 */
462export function modelToViewPosition( view ) {
463 return ( evt, data ) => {
464 if ( data.isPhantom ) {
465 return;
466 }
467
468 const modelItem = data.modelPosition.nodeBefore;
469
470 if ( modelItem && modelItem.is( 'element', 'listItem' ) ) {
471 const viewItem = data.mapper.toViewElement( modelItem );
472 const topmostViewList = viewItem.getAncestors().find( isList );
473 const walker = view.createPositionAt( viewItem, 0 ).getWalker();
474
475 for ( const value of walker ) {
476 if ( value.type == 'elementStart' && value.item.is( 'element', 'li' ) ) {
477 data.viewPosition = value.previousPosition;
478
479 break;
480 } else if ( value.type == 'elementEnd' && value.item == topmostViewList ) {
481 data.viewPosition = value.nextPosition;
482
483 break;
484 }
485 }
486 }
487 };
488}
489
490/**
491 * The callback for view position to model position mapping for {@link module:engine/conversion/mapper~Mapper}. The callback fixes
492 * positions between the `<li>` elements that would be incorrectly mapped because of how list items are represented in the model
493 * and in the view.
494 *
495 * @see module:engine/conversion/mapper~Mapper#event:viewToModelPosition
496 * @param {module:engine/model/model~Model} model Model instance.
497 * @returns {Function} Returns a conversion callback.
498 */
499export function viewToModelPosition( model ) {
500 return ( evt, data ) => {
501 const viewPos = data.viewPosition;
502 const viewParent = viewPos.parent;
503 const mapper = data.mapper;
504
505 if ( viewParent.name == 'ul' || viewParent.name == 'ol' ) {
506 // Position is directly in <ul> or <ol>.
507 if ( !viewPos.isAtEnd ) {
508 // If position is not at the end, it must be before <li>.
509 // Get that <li>, map it to `listItem` and set model position before that `listItem`.
510 const modelNode = mapper.toModelElement( viewPos.nodeAfter );
511
512 data.modelPosition = model.createPositionBefore( modelNode );
513 } else {
514 // Position is at the end of <ul> or <ol>, so there is no <li> after it to be mapped.
515 // There is <li> before the position, but we cannot just map it to `listItem` and set model position after it,
516 // because that <li> may contain nested items.
517 // We will check "model length" of that <li>, in other words - how many `listItem`s are in that <li>.
518 const modelNode = mapper.toModelElement( viewPos.nodeBefore );
519 const modelLength = mapper.getModelLength( viewPos.nodeBefore );
520
521 // Then we get model position before mapped `listItem` and shift it accordingly.
522 data.modelPosition = model.createPositionBefore( modelNode ).getShiftedBy( modelLength );
523 }
524
525 evt.stop();
526 } else if (
527 viewParent.name == 'li' &&
528 viewPos.nodeBefore &&
529 ( viewPos.nodeBefore.name == 'ul' || viewPos.nodeBefore.name == 'ol' )
530 ) {
531 // In most cases when view position is in <li> it is in text and this is a correct position.
532 // However, if position is after <ul> or <ol> we have to fix it -- because in model <ul>/<ol> are not in the `listItem`.
533 const modelNode = mapper.toModelElement( viewParent );
534
535 // Check all <ul>s and <ol>s that are in the <li> but before mapped position.
536 // Get model length of those elements and then add it to the offset of `listItem` mapped to the original <li>.
537 let modelLength = 1; // Starts from 1 because the original <li> has to be counted in too.
538 let viewList = viewPos.nodeBefore;
539
540 while ( viewList && isList( viewList ) ) {
541 modelLength += mapper.getModelLength( viewList );
542
543 viewList = viewList.previousSibling;
544 }
545
546 data.modelPosition = model.createPositionBefore( modelNode ).getShiftedBy( modelLength );
547
548 evt.stop();
549 }
550 };
551}
552
553/**
554 * Post-fixer that reacts to changes on document and fixes incorrect model states.
555 *
556 * In the example below, there is a correct list structure.
557 * Then the middle element is removed so the list structure will become incorrect:
558 *
559 * <listItem listType="bulleted" listIndent=0>Item 1</listItem>
560 * <listItem listType="bulleted" listIndent=1>Item 2</listItem> <--- this is removed.
561 * <listItem listType="bulleted" listIndent=2>Item 3</listItem>
562 *
563 * The list structure after the middle element is removed:
564 *
565 * <listItem listType="bulleted" listIndent=0>Item 1</listItem>
566 * <listItem listType="bulleted" listIndent=2>Item 3</listItem>
567 *
568 * Should become:
569 *
570 * <listItem listType="bulleted" listIndent=0>Item 1</listItem>
571 * <listItem listType="bulleted" listIndent=1>Item 3</listItem> <--- note that indent got post-fixed.
572 *
573 * @param {module:engine/model/model~Model} model The data model.
574 * @param {module:engine/model/writer~Writer} writer The writer to do changes with.
575 * @returns {Boolean} `true` if any change has been applied, `false` otherwise.
576 */
577export function modelChangePostFixer( model, writer ) {
578 const changes = model.document.differ.getChanges();
579 const itemToListHead = new Map();
580
581 let applied = false;
582
583 for ( const entry of changes ) {
584 if ( entry.type == 'insert' && entry.name == 'listItem' ) {
585 _addListToFix( entry.position );
586 } else if ( entry.type == 'insert' && entry.name != 'listItem' ) {
587 if ( entry.name != '$text' ) {
588 // In case of renamed element.
589 const item = entry.position.nodeAfter;
590
591 if ( item.hasAttribute( 'listIndent' ) ) {
592 writer.removeAttribute( 'listIndent', item );
593
594 applied = true;
595 }
596
597 if ( item.hasAttribute( 'listType' ) ) {
598 writer.removeAttribute( 'listType', item );
599
600 applied = true;
601 }
602
603 if ( item.hasAttribute( 'listStyle' ) ) {
604 writer.removeAttribute( 'listStyle', item );
605
606 applied = true;
607 }
608
609 if ( item.hasAttribute( 'listReversed' ) ) {
610 writer.removeAttribute( 'listReversed', item );
611
612 applied = true;
613 }
614
615 if ( item.hasAttribute( 'listStart' ) ) {
616 writer.removeAttribute( 'listStart', item );
617
618 applied = true;
619 }
620
621 for ( const innerItem of Array.from( model.createRangeIn( item ) ).filter( e => e.item.is( 'element', 'listItem' ) ) ) {
622 _addListToFix( innerItem.previousPosition );
623 }
624 }
625
626 const posAfter = entry.position.getShiftedBy( entry.length );
627
628 _addListToFix( posAfter );
629 } else if ( entry.type == 'remove' && entry.name == 'listItem' ) {
630 _addListToFix( entry.position );
631 } else if ( entry.type == 'attribute' && entry.attributeKey == 'listIndent' ) {
632 _addListToFix( entry.range.start );
633 } else if ( entry.type == 'attribute' && entry.attributeKey == 'listType' ) {
634 _addListToFix( entry.range.start );
635 }
636 }
637
638 for ( const listHead of itemToListHead.values() ) {
639 _fixListIndents( listHead );
640 _fixListTypes( listHead );
641 }
642
643 return applied;
644
645 function _addListToFix( position ) {
646 const previousNode = position.nodeBefore;
647
648 if ( !previousNode || !previousNode.is( 'element', 'listItem' ) ) {
649 const item = position.nodeAfter;
650
651 if ( item && item.is( 'element', 'listItem' ) ) {
652 itemToListHead.set( item, item );
653 }
654 } else {
655 let listHead = previousNode;
656
657 if ( itemToListHead.has( listHead ) ) {
658 return;
659 }
660
661 for (
662 // Cache previousSibling and reuse for performance reasons. See #6581.
663 let previousSibling = listHead.previousSibling;
664 previousSibling && previousSibling.is( 'element', 'listItem' );
665 previousSibling = listHead.previousSibling
666 ) {
667 listHead = previousSibling;
668
669 if ( itemToListHead.has( listHead ) ) {
670 return;
671 }
672 }
673
674 itemToListHead.set( previousNode, listHead );
675 }
676 }
677
678 function _fixListIndents( item ) {
679 let maxIndent = 0;
680 let fixBy = null;
681
682 while ( item && item.is( 'element', 'listItem' ) ) {
683 const itemIndent = item.getAttribute( 'listIndent' );
684
685 if ( itemIndent > maxIndent ) {
686 let newIndent;
687
688 if ( fixBy === null ) {
689 fixBy = itemIndent - maxIndent;
690 newIndent = maxIndent;
691 } else {
692 if ( fixBy > itemIndent ) {
693 fixBy = itemIndent;
694 }
695
696 newIndent = itemIndent - fixBy;
697 }
698
699 writer.setAttribute( 'listIndent', newIndent, item );
700
701 applied = true;
702 } else {
703 fixBy = null;
704 maxIndent = item.getAttribute( 'listIndent' ) + 1;
705 }
706
707 item = item.nextSibling;
708 }
709 }
710
711 function _fixListTypes( item ) {
712 let typesStack = [];
713 let prev = null;
714
715 while ( item && item.is( 'element', 'listItem' ) ) {
716 const itemIndent = item.getAttribute( 'listIndent' );
717
718 if ( prev && prev.getAttribute( 'listIndent' ) > itemIndent ) {
719 typesStack = typesStack.slice( 0, itemIndent + 1 );
720 }
721
722 if ( itemIndent != 0 ) {
723 if ( typesStack[ itemIndent ] ) {
724 const type = typesStack[ itemIndent ];
725
726 if ( item.getAttribute( 'listType' ) != type ) {
727 writer.setAttribute( 'listType', type, item );
728
729 applied = true;
730 }
731 } else {
732 typesStack[ itemIndent ] = item.getAttribute( 'listType' );
733 }
734 }
735
736 prev = item;
737 item = item.nextSibling;
738 }
739 }
740}
741
742/**
743 * A fixer for pasted content that includes list items.
744 *
745 * It fixes indentation of pasted list items so the pasted items match correctly to the context they are pasted into.
746 *
747 * Example:
748 *
749 * <listItem listType="bulleted" listIndent=0>A</listItem>
750 * <listItem listType="bulleted" listIndent=1>B^</listItem>
751 * // At ^ paste: <listItem listType="bulleted" listIndent=4>X</listItem>
752 * // <listItem listType="bulleted" listIndent=5>Y</listItem>
753 * <listItem listType="bulleted" listIndent=2>C</listItem>
754 *
755 * Should become:
756 *
757 * <listItem listType="bulleted" listIndent=0>A</listItem>
758 * <listItem listType="bulleted" listIndent=1>BX</listItem>
759 * <listItem listType="bulleted" listIndent=2>Y/listItem>
760 * <listItem listType="bulleted" listIndent=2>C</listItem>
761 *
762 * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
763 * @param {Array} args Arguments of {@link module:engine/model/model~Model#insertContent}.
764 */
765export function modelIndentPasteFixer( evt, [ content, selectable ] ) {
766 // Check whether inserted content starts from a `listItem`. If it does not, it means that there are some other
767 // elements before it and there is no need to fix indents, because even if we insert that content into a list,
768 // that list will be broken.
769 // Note: we also need to handle singular elements because inserting item with indent 0 into 0,1,[],2
770 // would create incorrect model.
771 let item = content.is( 'documentFragment' ) ? content.getChild( 0 ) : content;
772
773 let selection;
774
775 if ( !selectable ) {
776 selection = this.document.selection;
777 } else {
778 selection = this.createSelection( selectable );
779 }
780
781 if ( item && item.is( 'element', 'listItem' ) ) {
782 // Get a reference list item. Inserted list items will be fixed according to that item.
783 const pos = selection.getFirstPosition();
784 let refItem = null;
785
786 if ( pos.parent.is( 'element', 'listItem' ) ) {
787 refItem = pos.parent;
788 } else if ( pos.nodeBefore && pos.nodeBefore.is( 'element', 'listItem' ) ) {
789 refItem = pos.nodeBefore;
790 }
791
792 // If there is `refItem` it means that we do insert list items into an existing list.
793 if ( refItem ) {
794 // First list item in `data` has indent equal to 0 (it is a first list item). It should have indent equal
795 // to the indent of reference item. We have to fix the first item and all of it's children and following siblings.
796 // Indent of all those items has to be adjusted to reference item.
797 const indentChange = refItem.getAttribute( 'listIndent' );
798
799 // Fix only if there is anything to fix.
800 if ( indentChange > 0 ) {
801 // Adjust indent of all "first" list items in inserted data.
802 while ( item && item.is( 'element', 'listItem' ) ) {
803 item._setAttribute( 'listIndent', item.getAttribute( 'listIndent' ) + indentChange );
804
805 item = item.nextSibling;
806 }
807 }
808 }
809 }
810}
811
812// Helper function that converts children of a given `<li>` view element into corresponding model elements.
813// The function maintains proper order of elements if model `listItem` is split during the conversion
814// due to block children conversion.
815//
816// @param {module:engine/model/element~Element} listItemModel List item model element to which converted children will be inserted.
817// @param {Iterable.<module:engine/view/node~Node>} viewChildren View elements which will be converted.
818// @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface to be used by the callback.
819// @returns {module:engine/model/position~Position} Position on which next elements should be inserted after children conversion.
820function viewToModelListItemChildrenConverter( listItemModel, viewChildren, conversionApi ) {
821 const { writer, schema } = conversionApi;
822
823 // A position after the last inserted `listItem`.
824 let nextPosition = writer.createPositionAfter( listItemModel );
825
826 // Check all children of the converted `<li>`. At this point we assume there are no "whitespace" view text nodes
827 // in view list, between view list items. This should be handled by `<ul>` and `<ol>` converters.
828 for ( const child of viewChildren ) {
829 if ( child.name == 'ul' || child.name == 'ol' ) {
830 // If the children is a list, we will insert its conversion result after currently handled `listItem`.
831 // Then, next insertion position will be set after all the new list items (and maybe other elements if
832 // something split list item).
833 //
834 // If this is a list, we expect that some `listItem`s and possibly other blocks will be inserted, however `.modelCursor`
835 // should be set after last `listItem` (or block). This is why it feels safe to use it as `nextPosition`
836 nextPosition = conversionApi.convertItem( child, nextPosition ).modelCursor;
837 } else {
838 // If this is not a list, try inserting content at the end of the currently handled `listItem`.
839 const result = conversionApi.convertItem( child, writer.createPositionAt( listItemModel, 'end' ) );
840
841 // It may end up that the current `listItem` becomes split (if that content cannot be inside `listItem`). For example:
842 //
843 // <li><p>Foo</p></li>
844 //
845 // will be converted to:
846 //
847 // <listItem></listItem><paragraph>Foo</paragraph><listItem></listItem>
848 //
849 const convertedChild = result.modelRange.start.nodeAfter;
850 const wasSplit = convertedChild && convertedChild.is( 'element' ) && !schema.checkChild( listItemModel, convertedChild.name );
851
852 if ( wasSplit ) {
853 // As `lastListItem` got split, we need to update it to the second part of the split `listItem` element.
854 //
855 // `modelCursor` should be set to a position where the conversion should continue. There are multiple possible scenarios
856 // that may happen. Usually, `modelCursor` (marked as `#` below) would point to the second list item after conversion:
857 //
858 // `<li><p>Foo</p></li>` -> `<listItem></listItem><paragraph>Foo</paragraph><listItem>#</listItem>`
859 //
860 // However, in some cases, like auto-paragraphing, the position is placed at the end of the block element:
861 //
862 // `<li><div>Foo</div></li>` -> `<listItem></listItem><paragraph>Foo#</paragraph><listItem></listItem>`
863 //
864 // or after an element if another element broken auto-paragraphed element:
865 //
866 // `<li><div><h2>Foo</h2></div></li>` -> `<listItem></listItem><heading1>Foo</heading1>#<listItem></listItem>`
867 //
868 // We need to check for such cases and use proper list item and position based on it.
869 //
870 if ( result.modelCursor.parent.is( 'element', 'listItem' ) ) {
871 // (1).
872 listItemModel = result.modelCursor.parent;
873 } else {
874 // (2), (3).
875 listItemModel = findNextListItem( result.modelCursor );
876 }
877
878 nextPosition = writer.createPositionAfter( listItemModel );
879 }
880 }
881 }
882
883 return nextPosition;
884}
885
886// Helper function that seeks for a next list item starting from given `startPosition`.
887function findNextListItem( startPosition ) {
888 const treeWalker = new TreeWalker( { startPosition } );
889
890 let value;
891
892 do {
893 value = treeWalker.next();
894 } while ( !value.value.item.is( 'element', 'listItem' ) );
895
896 return value.value.item;
897}
898
899// Helper function that takes all children of given `viewRemovedItem` and moves them in a correct place, according
900// to other given parameters.
901function hoistNestedLists( nextIndent, modelRemoveStartPosition, viewRemoveStartPosition, viewRemovedItem, conversionApi, model ) {
902 // Find correct previous model list item element.
903 // The element has to have either same or smaller indent than given reference indent.
904 // This will be the model element which will get nested items (if it has smaller indent) or sibling items (if it has same indent).
905 // Keep in mind that such element might not be found, if removed item was the first item.
906 const prevModelItem = getSiblingListItem( modelRemoveStartPosition.nodeBefore, {
907 sameIndent: true,
908 smallerIndent: true,
909 listIndent: nextIndent,
910 foo: 'b'
911 } );
912
913 const mapper = conversionApi.mapper;
914 const viewWriter = conversionApi.writer;
915
916 // Indent of found element or `null` if the element has not been found.
917 const prevIndent = prevModelItem ? prevModelItem.getAttribute( 'listIndent' ) : null;
918
919 let insertPosition;
920
921 if ( !prevModelItem ) {
922 // If element has not been found, simply insert lists at the position where the removed item was:
923 //
924 // Lorem ipsum.
925 // 1 -------- <--- this is removed, no previous list item, put nested items in place of removed item.
926 // 1.1 -------- <--- this is reference indent.
927 // 1.1.1 --------
928 // 1.1.2 --------
929 // 1.2 --------
930 //
931 // Becomes:
932 //
933 // Lorem ipsum.
934 // 1.1 --------
935 // 1.1.1 --------
936 // 1.1.2 --------
937 // 1.2 --------
938 insertPosition = viewRemoveStartPosition;
939 } else if ( prevIndent == nextIndent ) {
940 // If element has been found and has same indent as reference indent it means that nested items should
941 // become siblings of found element:
942 //
943 // 1 --------
944 // 1.1 --------
945 // 1.2 -------- <--- this is `prevModelItem`.
946 // 2 -------- <--- this is removed, previous list item has indent same as reference indent.
947 // 2.1 -------- <--- this is reference indent, this and 2.2 should become siblings of 1.2.
948 // 2.2 --------
949 //
950 // Becomes:
951 //
952 // 1 --------
953 // 1.1 --------
954 // 1.2 --------
955 // 2.1 --------
956 // 2.2 --------
957 const prevViewList = mapper.toViewElement( prevModelItem ).parent;
958 insertPosition = viewWriter.createPositionAfter( prevViewList );
959 } else {
960 // If element has been found and has smaller indent as reference indent it means that nested items
961 // should become nested items of found item:
962 //
963 // 1 -------- <--- this is `prevModelItem`.
964 // 1.1 -------- <--- this is removed, previous list item has indent smaller than reference indent.
965 // 1.1.1 -------- <--- this is reference indent, this and 1.1.1 should become nested items of 1.
966 // 1.1.2 --------
967 // 1.2 --------
968 //
969 // Becomes:
970 //
971 // 1 --------
972 // 1.1.1 --------
973 // 1.1.2 --------
974 // 1.2 --------
975 //
976 // Note: in this case 1.1.1 have indent 2 while 1 have indent 0. In model that should not be possible,
977 // because following item may have indent bigger only by one. But this is fixed by postfixer.
978 const modelPosition = model.createPositionAt( prevModelItem, 'end' );
979 insertPosition = mapper.toViewPosition( modelPosition );
980 }
981
982 insertPosition = positionAfterUiElements( insertPosition );
983
984 // Handle multiple lists. This happens if list item has nested numbered and bulleted lists. Following lists
985 // are inserted after the first list (no need to recalculate insertion position for them).
986 for ( const child of [ ...viewRemovedItem.getChildren() ] ) {
987 if ( isList( child ) ) {
988 insertPosition = viewWriter.move( viewWriter.createRangeOn( child ), insertPosition ).end;
989
990 mergeViewLists( viewWriter, child, child.nextSibling );
991 mergeViewLists( viewWriter, child.previousSibling, child );
992 }
993 }
994}
995
996// Checks if view element is a list type (ul or ol).
997//
998// @param {module:engine/view/element~Element} viewElement
999// @returns {Boolean}
1000function isList( viewElement ) {
1001 return viewElement.is( 'element', 'ol' ) || viewElement.is( 'element', 'ul' );
1002}
1003
1004// Calculates the indent value for a list item. Handles HTML compliant and non-compliant lists.
1005//
1006// Also, fixes non HTML compliant lists indents:
1007//
1008// before: fixed list:
1009// OL OL
1010// |-> LI (parent LIs: 0) |-> LI (indent: 0)
1011// |-> OL |-> OL
1012// |-> OL |
1013// | |-> OL |
1014// | |-> OL |
1015// | |-> LI (parent LIs: 1) |-> LI (indent: 1)
1016// |-> LI (parent LIs: 1) |-> LI (indent: 1)
1017//
1018// before: fixed list:
1019// OL OL
1020// |-> OL |
1021// |-> OL |
1022// |-> OL |
1023// |-> LI (parent LIs: 0) |-> LI (indent: 0)
1024//
1025// before: fixed list:
1026// OL OL
1027// |-> LI (parent LIs: 0) |-> LI (indent: 0)
1028// |-> OL |-> OL
1029// |-> LI (parent LIs: 0) |-> LI (indent: 1)
1030//
1031// @param {module:engine/view/element~Element} listItem
1032// @param {Object} conversionStore
1033// @returns {Number}
1034function getIndent( listItem ) {
1035 let indent = 0;
1036
1037 let parent = listItem.parent;
1038
1039 while ( parent ) {
1040 // Each LI in the tree will result in an increased indent for HTML compliant lists.
1041 if ( parent.is( 'element', 'li' ) ) {
1042 indent++;
1043 } else {
1044 // If however the list is nested in other list we should check previous sibling of any of the list elements...
1045 const previousSibling = parent.previousSibling;
1046
1047 // ...because the we might need increase its indent:
1048 // before: fixed list:
1049 // OL OL
1050 // |-> LI (parent LIs: 0) |-> LI (indent: 0)
1051 // |-> OL |-> OL
1052 // |-> LI (parent LIs: 0) |-> LI (indent: 1)
1053 if ( previousSibling && previousSibling.is( 'element', 'li' ) ) {
1054 indent++;
1055 }
1056 }
1057
1058 parent = parent.parent;
1059 }
1060
1061 return indent;
1062}