UNPKG

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