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 | */
|
8 | import { TreeWalker } from 'ckeditor5/src/engine';
|
9 | import { 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 | */
|
19 | export 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 | */
|
42 | export 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 | */
|
83 | export 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 | */
|
104 | export 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 | */
|
120 | export 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 | */
|
172 | export 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 | */
|
284 | export 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 | */
|
302 | export 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 | */
|
329 | export 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 | */
|
346 | export 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 | */
|
369 | export 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 | */
|
401 | export 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 | */
|
475 | export 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 | */
|
629 | export 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 | */
|
681 | function 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 | */
|
745 | function 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 | */
|
757 | function 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 | */
|
848 | function 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 | */
|
881 | function 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 | }
|