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/listproperties/listpropertiesediting
|
7 | */
|
8 | import { Plugin } from 'ckeditor5/src/core';
|
9 | import ListEditing from '../list/listediting';
|
10 | import ListStyleCommand from './liststylecommand';
|
11 | import ListReversedCommand from './listreversedcommand';
|
12 | import ListStartCommand from './liststartcommand';
|
13 | import { getSiblingListItem, getSiblingNodes } from '../list/utils';
|
14 | const DEFAULT_LIST_TYPE = 'default';
|
15 | /**
|
16 | * The engine of the list properties feature.
|
17 | *
|
18 | * It sets the value for the `listItem` attribute of the {@link module:list/list~List `<listItem>`} element that
|
19 | * allows modifying the list style type.
|
20 | *
|
21 | * It registers the `'listStyle'`, `'listReversed'` and `'listStart'` commands if they are enabled in the configuration.
|
22 | * Read more in {@link module:list/listconfig~ListPropertiesConfig}.
|
23 | */
|
24 | export default class ListPropertiesEditing extends Plugin {
|
25 | /**
|
26 | * @inheritDoc
|
27 | */
|
28 | static get requires() {
|
29 | return [ListEditing];
|
30 | }
|
31 | /**
|
32 | * @inheritDoc
|
33 | */
|
34 | static get pluginName() {
|
35 | return 'ListPropertiesEditing';
|
36 | }
|
37 | /**
|
38 | * @inheritDoc
|
39 | */
|
40 | constructor(editor) {
|
41 | super(editor);
|
42 | editor.config.define('list', {
|
43 | properties: {
|
44 | styles: true,
|
45 | startIndex: false,
|
46 | reversed: false
|
47 | }
|
48 | });
|
49 | }
|
50 | /**
|
51 | * @inheritDoc
|
52 | */
|
53 | init() {
|
54 | const editor = this.editor;
|
55 | const model = editor.model;
|
56 | const enabledProperties = editor.config.get('list.properties');
|
57 | const strategies = createAttributeStrategies(enabledProperties);
|
58 | // Extend schema.
|
59 | model.schema.extend('listItem', {
|
60 | allowAttributes: strategies.map(s => s.attributeName)
|
61 | });
|
62 | for (const strategy of strategies) {
|
63 | strategy.addCommand(editor);
|
64 | }
|
65 | // Fix list attributes when modifying their nesting levels (the `listIndent` attribute).
|
66 | this.listenTo(editor.commands.get('indentList'), '_executeCleanup', fixListAfterIndentListCommand(editor, strategies));
|
67 | this.listenTo(editor.commands.get('outdentList'), '_executeCleanup', fixListAfterOutdentListCommand(editor, strategies));
|
68 | this.listenTo(editor.commands.get('bulletedList'), '_executeCleanup', restoreDefaultListStyle(editor));
|
69 | this.listenTo(editor.commands.get('numberedList'), '_executeCleanup', restoreDefaultListStyle(editor));
|
70 | // Register a post-fixer that ensures that the attributes is specified in each `listItem` element.
|
71 | model.document.registerPostFixer(fixListAttributesOnListItemElements(editor, strategies));
|
72 | // Set up conversion.
|
73 | editor.conversion.for('upcast').add(upcastListItemAttributes(strategies));
|
74 | editor.conversion.for('downcast').add(downcastListItemAttributes(strategies));
|
75 | // Handle merging two separated lists into the single one.
|
76 | this._mergeListAttributesWhileMergingLists(strategies);
|
77 | }
|
78 | /**
|
79 | * @inheritDoc
|
80 | */
|
81 | afterInit() {
|
82 | const editor = this.editor;
|
83 | // Enable post-fixer that removes the attributes from to-do list items only if the "TodoList" plugin is on.
|
84 | // We need to registry the hook here since the `TodoList` plugin can be added after the `ListPropertiesEditing`.
|
85 | if (editor.commands.get('todoList')) {
|
86 | editor.model.document.registerPostFixer(removeListItemAttributesFromTodoList(editor));
|
87 | }
|
88 | }
|
89 | /**
|
90 | * Starts listening to {@link module:engine/model/model~Model#deleteContent} and checks whether two lists will be merged into a single
|
91 | * one after deleting the content.
|
92 | *
|
93 | * The purpose of this action is to adjust the `listStyle`, `listReversed` and `listStart` values
|
94 | * for the list that was merged.
|
95 | *
|
96 | * Consider the following model's content:
|
97 | *
|
98 | * ```xml
|
99 | * <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
|
100 | * <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 2</listItem>
|
101 | * <paragraph>[A paragraph.]</paragraph>
|
102 | * <listItem listIndent="0" listType="bulleted" listStyle="circle">UL List item 1</listItem>
|
103 | * <listItem listIndent="0" listType="bulleted" listStyle="circle">UL List item 2</listItem>
|
104 | * ```
|
105 | *
|
106 | * After removing the paragraph element, the second list will be merged into the first one.
|
107 | * We want to inherit the `listStyle` attribute for the second list from the first one.
|
108 | *
|
109 | * ```xml
|
110 | * <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
|
111 | * <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 2</listItem>
|
112 | * <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
|
113 | * <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 2</listItem>
|
114 | * ```
|
115 | *
|
116 | * See https://github.com/ckeditor/ckeditor5/issues/7879.
|
117 | *
|
118 | * @param attributeStrategies Strategies for the enabled attributes.
|
119 | */
|
120 | _mergeListAttributesWhileMergingLists(attributeStrategies) {
|
121 | const editor = this.editor;
|
122 | const model = editor.model;
|
123 | // First the outer-most`listItem` in the first list reference.
|
124 | // If found, the lists should be merged and this `listItem` provides the attributes
|
125 | // and it is also a starting point when searching for items in the second list.
|
126 | let firstMostOuterItem;
|
127 | // Check whether the removed content is between two lists.
|
128 | this.listenTo(model, 'deleteContent', (evt, [selection]) => {
|
129 | const firstPosition = selection.getFirstPosition();
|
130 | const lastPosition = selection.getLastPosition();
|
131 | // Typing or removing content in a single item. Aborting.
|
132 | if (firstPosition.parent === lastPosition.parent) {
|
133 | return;
|
134 | }
|
135 | // An element before the content that will be removed is not a list.
|
136 | if (!firstPosition.parent.is('element', 'listItem')) {
|
137 | return;
|
138 | }
|
139 | const nextSibling = lastPosition.parent.nextSibling;
|
140 | // An element after the content that will be removed is not a list.
|
141 | if (!nextSibling || !nextSibling.is('element', 'listItem')) {
|
142 | return;
|
143 | }
|
144 | // Find the outermost list item based on the `listIndent` attribute. We can't assume that `listIndent=0`
|
145 | // because the selection can be hooked in nested lists.
|
146 | //
|
147 | // <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
|
148 | // <listItem listIndent="1" listType="bulleted" listStyle="square">UL List [item 1.1</listItem>
|
149 | // <listItem listIndent="0" listType="bulleted" listStyle="circle">[]UL List item 1.</listItem>
|
150 | // <listItem listIndent="1" listType="bulleted" listStyle="circle">UL List ]item 1.1</listItem>
|
151 | //
|
152 | // After deleting the content, we would like to inherit the "square" attribute for the last element:
|
153 | //
|
154 | // <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
|
155 | // <listItem listIndent="1" listType="bulleted" listStyle="square">UL List []item 1.1</listItem>
|
156 | const mostOuterItemList = getSiblingListItem(firstPosition.parent, {
|
157 | sameIndent: true,
|
158 | listIndent: nextSibling.getAttribute('listIndent')
|
159 | });
|
160 | // The outermost list item may not exist while removing elements between lists with different value
|
161 | // of the `listIndent` attribute. In such a case we don't want to update anything. See: #8073.
|
162 | if (!mostOuterItemList) {
|
163 | return;
|
164 | }
|
165 | if (mostOuterItemList.getAttribute('listType') === nextSibling.getAttribute('listType')) {
|
166 | firstMostOuterItem = mostOuterItemList;
|
167 | }
|
168 | }, { priority: 'high' });
|
169 | // If so, update the `listStyle` attribute for the second list.
|
170 | this.listenTo(model, 'deleteContent', () => {
|
171 | if (!firstMostOuterItem) {
|
172 | return;
|
173 | }
|
174 | model.change(writer => {
|
175 | // Find the first most-outer item list in the merged list.
|
176 | // A case when the first list item in the second list was merged into the last item in the first list.
|
177 | //
|
178 | // <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
|
179 | // <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 2</listItem>
|
180 | // <listItem listIndent="0" listType="bulleted" listStyle="circle">[]UL List item 1</listItem>
|
181 | // <listItem listIndent="0" listType="bulleted" listStyle="circle">UL List item 2</listItem>
|
182 | const secondListMostOuterItem = getSiblingListItem(firstMostOuterItem.nextSibling, {
|
183 | sameIndent: true,
|
184 | listIndent: firstMostOuterItem.getAttribute('listIndent'),
|
185 | direction: 'forward'
|
186 | });
|
187 | // If the selection ends in a non-list element, there are no <listItem>s that would require adjustments.
|
188 | // See: #8642.
|
189 | if (!secondListMostOuterItem) {
|
190 | firstMostOuterItem = null;
|
191 | return;
|
192 | }
|
193 | const items = [
|
194 | secondListMostOuterItem,
|
195 | ...getSiblingNodes(writer.createPositionAt(secondListMostOuterItem, 0), 'forward')
|
196 | ];
|
197 | for (const listItem of items) {
|
198 | for (const strategy of attributeStrategies) {
|
199 | if (strategy.appliesToListItem(listItem)) {
|
200 | const attributeName = strategy.attributeName;
|
201 | const value = firstMostOuterItem.getAttribute(attributeName);
|
202 | writer.setAttribute(attributeName, value, listItem);
|
203 | }
|
204 | }
|
205 | }
|
206 | });
|
207 | firstMostOuterItem = null;
|
208 | }, { priority: 'low' });
|
209 | }
|
210 | }
|
211 | /**
|
212 | * Creates an array of strategies for dealing with enabled listItem attributes.
|
213 | */
|
214 | function createAttributeStrategies(enabledProperties) {
|
215 | const strategies = [];
|
216 | if (enabledProperties.styles) {
|
217 | strategies.push({
|
218 | attributeName: 'listStyle',
|
219 | defaultValue: DEFAULT_LIST_TYPE,
|
220 | addCommand(editor) {
|
221 | editor.commands.add('listStyle', new ListStyleCommand(editor, DEFAULT_LIST_TYPE));
|
222 | },
|
223 | appliesToListItem() {
|
224 | return true;
|
225 | },
|
226 | setAttributeOnDowncast(writer, listStyle, element) {
|
227 | if (listStyle && listStyle !== DEFAULT_LIST_TYPE) {
|
228 | writer.setStyle('list-style-type', listStyle, element);
|
229 | }
|
230 | else {
|
231 | writer.removeStyle('list-style-type', element);
|
232 | }
|
233 | },
|
234 | getAttributeOnUpcast(listParent) {
|
235 | return listParent.getStyle('list-style-type') || DEFAULT_LIST_TYPE;
|
236 | }
|
237 | });
|
238 | }
|
239 | if (enabledProperties.reversed) {
|
240 | strategies.push({
|
241 | attributeName: 'listReversed',
|
242 | defaultValue: false,
|
243 | addCommand(editor) {
|
244 | editor.commands.add('listReversed', new ListReversedCommand(editor));
|
245 | },
|
246 | appliesToListItem(item) {
|
247 | return item.getAttribute('listType') == 'numbered';
|
248 | },
|
249 | setAttributeOnDowncast(writer, listReversed, element) {
|
250 | if (listReversed) {
|
251 | writer.setAttribute('reversed', 'reversed', element);
|
252 | }
|
253 | else {
|
254 | writer.removeAttribute('reversed', element);
|
255 | }
|
256 | },
|
257 | getAttributeOnUpcast(listParent) {
|
258 | return listParent.hasAttribute('reversed');
|
259 | }
|
260 | });
|
261 | }
|
262 | if (enabledProperties.startIndex) {
|
263 | strategies.push({
|
264 | attributeName: 'listStart',
|
265 | defaultValue: 1,
|
266 | addCommand(editor) {
|
267 | editor.commands.add('listStart', new ListStartCommand(editor));
|
268 | },
|
269 | appliesToListItem(item) {
|
270 | return item.getAttribute('listType') == 'numbered';
|
271 | },
|
272 | setAttributeOnDowncast(writer, listStart, element) {
|
273 | if (listStart == 0 || listStart > 1) {
|
274 | writer.setAttribute('start', listStart, element);
|
275 | }
|
276 | else {
|
277 | writer.removeAttribute('start', element);
|
278 | }
|
279 | },
|
280 | getAttributeOnUpcast(listParent) {
|
281 | const startAttributeValue = listParent.getAttribute('start');
|
282 | return startAttributeValue >= 0 ? startAttributeValue : 1;
|
283 | }
|
284 | });
|
285 | }
|
286 | return strategies;
|
287 | }
|
288 | /**
|
289 | * Returns a converter consumes the `style`, `reversed` and `start` attribute.
|
290 | * In `style` it searches for the `list-style-type` definition.
|
291 | * If not found, the `"default"` value will be used.
|
292 | */
|
293 | function upcastListItemAttributes(attributeStrategies) {
|
294 | return (dispatcher) => {
|
295 | dispatcher.on('element:li', (evt, data, conversionApi) => {
|
296 | // https://github.com/ckeditor/ckeditor5/issues/13858
|
297 | if (!data.modelRange) {
|
298 | return;
|
299 | }
|
300 | const listParent = data.viewItem.parent;
|
301 | const listItem = data.modelRange.start.nodeAfter || data.modelRange.end.nodeBefore;
|
302 | for (const strategy of attributeStrategies) {
|
303 | if (strategy.appliesToListItem(listItem)) {
|
304 | const listStyle = strategy.getAttributeOnUpcast(listParent);
|
305 | conversionApi.writer.setAttribute(strategy.attributeName, listStyle, listItem);
|
306 | }
|
307 | }
|
308 | }, { priority: 'low' });
|
309 | };
|
310 | }
|
311 | /**
|
312 | * Returns a converter that adds `reversed`, `start` attributes and adds `list-style-type` definition as a value for the `style` attribute.
|
313 | * The `"default"` values are removed and not present in the view/data.
|
314 | */
|
315 | function downcastListItemAttributes(attributeStrategies) {
|
316 | return (dispatcher) => {
|
317 | for (const strategy of attributeStrategies) {
|
318 | dispatcher.on(`attribute:${strategy.attributeName}:listItem`, (evt, data, conversionApi) => {
|
319 | const viewWriter = conversionApi.writer;
|
320 | const currentElement = data.item;
|
321 | const previousElement = getSiblingListItem(currentElement.previousSibling, {
|
322 | sameIndent: true,
|
323 | listIndent: currentElement.getAttribute('listIndent'),
|
324 | direction: 'backward'
|
325 | });
|
326 | const viewItem = conversionApi.mapper.toViewElement(currentElement);
|
327 | // A case when elements represent different lists. We need to separate their container.
|
328 | if (!areRepresentingSameList(currentElement, previousElement)) {
|
329 | viewWriter.breakContainer(viewWriter.createPositionBefore(viewItem));
|
330 | }
|
331 | strategy.setAttributeOnDowncast(viewWriter, data.attributeNewValue, viewItem.parent);
|
332 | }, { priority: 'low' });
|
333 | }
|
334 | };
|
335 | /**
|
336 | * Checks whether specified list items belong to the same list.
|
337 | */
|
338 | function areRepresentingSameList(listItem1, listItem2) {
|
339 | return listItem2 &&
|
340 | listItem1.getAttribute('listType') === listItem2.getAttribute('listType') &&
|
341 | listItem1.getAttribute('listIndent') === listItem2.getAttribute('listIndent') &&
|
342 | listItem1.getAttribute('listStyle') === listItem2.getAttribute('listStyle') &&
|
343 | listItem1.getAttribute('listReversed') === listItem2.getAttribute('listReversed') &&
|
344 | listItem1.getAttribute('listStart') === listItem2.getAttribute('listStart');
|
345 | }
|
346 | }
|
347 | /**
|
348 | * When indenting list, nested list should clear its value for the attributes or inherit from nested lists.
|
349 | *
|
350 | * ■ List item 1.
|
351 | * ■ List item 2.[]
|
352 | * ■ List item 3.
|
353 | * editor.execute( 'indentList' );
|
354 | *
|
355 | * ■ List item 1.
|
356 | * ○ List item 2.[]
|
357 | * ■ List item 3.
|
358 | */
|
359 | function fixListAfterIndentListCommand(editor, attributeStrategies) {
|
360 | return (evt, changedItems) => {
|
361 | const root = changedItems[0];
|
362 | const rootIndent = root.getAttribute('listIndent');
|
363 | const itemsToUpdate = changedItems.filter(item => item.getAttribute('listIndent') === rootIndent);
|
364 | // A case where a few list items are indented must be checked separately
|
365 | // since `getSiblingListItem()` returns the first changed element.
|
366 | // ■ List item 1.
|
367 | // ○ [List item 2.
|
368 | // ○ List item 3.]
|
369 | // ■ List item 4.
|
370 | //
|
371 | // List items: `2` and `3` should be adjusted.
|
372 | let previousSibling = null;
|
373 | if (root.previousSibling.getAttribute('listIndent') + 1 !== rootIndent) {
|
374 | previousSibling = getSiblingListItem(root.previousSibling, {
|
375 | sameIndent: true, direction: 'backward', listIndent: rootIndent
|
376 | });
|
377 | }
|
378 | editor.model.change(writer => {
|
379 | for (const item of itemsToUpdate) {
|
380 | for (const strategy of attributeStrategies) {
|
381 | if (strategy.appliesToListItem(item)) {
|
382 | const valueToSet = previousSibling == null ?
|
383 | strategy.defaultValue :
|
384 | previousSibling.getAttribute(strategy.attributeName);
|
385 | writer.setAttribute(strategy.attributeName, valueToSet, item);
|
386 | }
|
387 | }
|
388 | }
|
389 | });
|
390 | };
|
391 | }
|
392 | /**
|
393 | * When outdenting a list, a nested list should copy attribute values
|
394 | * from the previous sibling list item including the same value for the `listIndent` value.
|
395 | *
|
396 | * ■ List item 1.
|
397 | * ○ List item 2.[]
|
398 | * ■ List item 3.
|
399 | *
|
400 | * editor.execute( 'outdentList' );
|
401 | *
|
402 | * ■ List item 1.
|
403 | * ■ List item 2.[]
|
404 | * ■ List item 3.
|
405 | */
|
406 | function fixListAfterOutdentListCommand(editor, attributeStrategies) {
|
407 | return (evt, changedItems) => {
|
408 | changedItems = changedItems.reverse().filter(item => item.is('element', 'listItem'));
|
409 | if (!changedItems.length) {
|
410 | return;
|
411 | }
|
412 | const indent = changedItems[0].getAttribute('listIndent');
|
413 | const listType = changedItems[0].getAttribute('listType');
|
414 | let listItem = changedItems[0].previousSibling;
|
415 | // ■ List item 1.
|
416 | // ○ List item 2.
|
417 | // ○ List item 3.[]
|
418 | // ■ List item 4.
|
419 | //
|
420 | // After outdenting a list, `List item 3` should inherit the `listStyle` attribute from `List item 1`.
|
421 | //
|
422 | // ■ List item 1.
|
423 | // ○ List item 2.
|
424 | // ■ List item 3.[]
|
425 | // ■ List item 4.
|
426 | if (listItem.is('element', 'listItem')) {
|
427 | while (listItem.getAttribute('listIndent') !== indent) {
|
428 | listItem = listItem.previousSibling;
|
429 | }
|
430 | }
|
431 | else {
|
432 | listItem = null;
|
433 | }
|
434 | // Outdenting such a list should restore values based on `List item 4`.
|
435 | // ■ List item 1.[]
|
436 | // ○ List item 2.
|
437 | // ○ List item 3.
|
438 | // ■ List item 4.
|
439 | if (!listItem) {
|
440 | listItem = changedItems[changedItems.length - 1].nextSibling;
|
441 | }
|
442 | // And such a list should not modify anything.
|
443 | // However, `listItem` can indicate a node below the list. Be sure that we have the `listItem` element.
|
444 | // ■ List item 1.[]
|
445 | // ○ List item 2.
|
446 | // ○ List item 3.
|
447 | // <paragraph>The later if check.</paragraph>
|
448 | if (!listItem || !listItem.is('element', 'listItem')) {
|
449 | return;
|
450 | }
|
451 | // Do not modify the list if found `listItem` represents other type of list than outdented list items.
|
452 | if (listItem.getAttribute('listType') !== listType) {
|
453 | return;
|
454 | }
|
455 | editor.model.change(writer => {
|
456 | const itemsToUpdate = changedItems.filter(item => item.getAttribute('listIndent') === indent);
|
457 | for (const item of itemsToUpdate) {
|
458 | for (const strategy of attributeStrategies) {
|
459 | if (strategy.appliesToListItem(item)) {
|
460 | const attributeName = strategy.attributeName;
|
461 | const valueToSet = listItem.getAttribute(attributeName);
|
462 | writer.setAttribute(attributeName, valueToSet, item);
|
463 | }
|
464 | }
|
465 | }
|
466 | });
|
467 | };
|
468 | }
|
469 | /**
|
470 | * Each `listItem` element must have specified the `listStyle`, `listReversed` and `listStart` attributes
|
471 | * if they are enabled and supported by its `listType`.
|
472 | * This post-fixer checks whether inserted elements `listItem` elements should inherit the attribute values from
|
473 | * their sibling nodes or should use the default values.
|
474 | *
|
475 | * Paragraph[]
|
476 | * ■ List item 1. // [listStyle="square", listType="bulleted"]
|
477 | * ■ List item 2. // ...
|
478 | * ■ List item 3. // ...
|
479 | *
|
480 | * editor.execute( 'bulletedList' )
|
481 | *
|
482 | * ■ Paragraph[] // [listStyle="square", listType="bulleted"]
|
483 | * ■ List item 1. // [listStyle="square", listType="bulleted"]
|
484 | * ■ List item 2.
|
485 | * ■ List item 3.
|
486 | *
|
487 | * It also covers a such change:
|
488 | *
|
489 | * [Paragraph 1
|
490 | * Paragraph 2]
|
491 | * ■ List item 1. // [listStyle="square", listType="bulleted"]
|
492 | * ■ List item 2. // ...
|
493 | * ■ List item 3. // ...
|
494 | *
|
495 | * editor.execute( 'numberedList' )
|
496 | *
|
497 | * 1. [Paragraph 1 // [listStyle="default", listType="numbered"]
|
498 | * 2. Paragraph 2] // [listStyle="default", listType="numbered"]
|
499 | * ■ List item 1. // [listStyle="square", listType="bulleted"]
|
500 | * ■ List item 2. // ...
|
501 | * ■ List item 3. // ...
|
502 | */
|
503 | function fixListAttributesOnListItemElements(editor, attributeStrategies) {
|
504 | return (writer) => {
|
505 | let wasFixed = false;
|
506 | const insertedListItems = getChangedListItems(editor.model.document.differ.getChanges())
|
507 | .filter(item => {
|
508 | // Don't touch todo lists. They are handled in another post-fixer.
|
509 | return item.getAttribute('listType') !== 'todo';
|
510 | });
|
511 | if (!insertedListItems.length) {
|
512 | return wasFixed;
|
513 | }
|
514 | // Check whether the last inserted element is next to the `listItem` element.
|
515 | //
|
516 | // ■ Paragraph[] // <-- The inserted item.
|
517 | // ■ List item 1.
|
518 | let existingListItem = insertedListItems[insertedListItems.length - 1].nextSibling;
|
519 | // If it doesn't, maybe the `listItem` was inserted at the end of the list.
|
520 | //
|
521 | // ■ List item 1.
|
522 | // ■ Paragraph[] // <-- The inserted item.
|
523 | if (!existingListItem || !existingListItem.is('element', 'listItem')) {
|
524 | existingListItem = insertedListItems[0].previousSibling;
|
525 | if (existingListItem) {
|
526 | const indent = insertedListItems[0].getAttribute('listIndent');
|
527 | // But we need to find a `listItem` with the `listIndent=0` attribute.
|
528 | // If doesn't, maybe the `listItem` was inserted at the end of the list.
|
529 | //
|
530 | // ■ List item 1.
|
531 | // ○ List item 2.
|
532 | // ■ Paragraph[] // <-- The inserted item.
|
533 | while (existingListItem.is('element', 'listItem') && existingListItem.getAttribute('listIndent') !== indent) {
|
534 | existingListItem = existingListItem.previousSibling;
|
535 | // If the item does not exist, most probably there is no other content in the editor. See: #8072.
|
536 | if (!existingListItem) {
|
537 | break;
|
538 | }
|
539 | }
|
540 | }
|
541 | }
|
542 | for (const strategy of attributeStrategies) {
|
543 | const attributeName = strategy.attributeName;
|
544 | for (const item of insertedListItems) {
|
545 | if (!strategy.appliesToListItem(item)) {
|
546 | writer.removeAttribute(attributeName, item);
|
547 | continue;
|
548 | }
|
549 | if (!item.hasAttribute(attributeName)) {
|
550 | if (shouldInheritListType(existingListItem, item, strategy)) {
|
551 | writer.setAttribute(attributeName, existingListItem.getAttribute(attributeName), item);
|
552 | }
|
553 | else {
|
554 | writer.setAttribute(attributeName, strategy.defaultValue, item);
|
555 | }
|
556 | wasFixed = true;
|
557 | }
|
558 | else {
|
559 | // Adjust the `listStyle`, `listReversed` and `listStart`
|
560 | // attributes for inserted (pasted) items. See #8160.
|
561 | //
|
562 | // ■ List item 1. // [listStyle="square", listType="bulleted"]
|
563 | // ○ List item 1.1. // [listStyle="circle", listType="bulleted"]
|
564 | // ○ [] (selection is here)
|
565 | //
|
566 | // Then, pasting a list with different attributes (listStyle, listType):
|
567 | //
|
568 | // 1. First. // [listStyle="decimal", listType="numbered"]
|
569 | // 2. Second // [listStyle="decimal", listType="numbered"]
|
570 | //
|
571 | // The `listType` attribute will be corrected by the `ListEditing` converters.
|
572 | // We need to adjust the `listStyle` attribute. Expected structure:
|
573 | //
|
574 | // ■ List item 1. // [listStyle="square", listType="bulleted"]
|
575 | // ○ List item 1.1. // [listStyle="circle", listType="bulleted"]
|
576 | // ○ First. // [listStyle="circle", listType="bulleted"]
|
577 | // ○ Second // [listStyle="circle", listType="bulleted"]
|
578 | const previousSibling = item.previousSibling;
|
579 | if (shouldInheritListTypeFromPreviousItem(previousSibling, item, strategy.attributeName)) {
|
580 | writer.setAttribute(attributeName, previousSibling.getAttribute(attributeName), item);
|
581 | wasFixed = true;
|
582 | }
|
583 | }
|
584 | }
|
585 | }
|
586 | return wasFixed;
|
587 | };
|
588 | }
|
589 | /**
|
590 | * Checks whether the `listStyle`, `listReversed` and `listStart` attributes
|
591 | * should be copied from the `baseItem` element.
|
592 | *
|
593 | * The attribute should be copied if the inserted element does not have defined it and
|
594 | * the value for the element is other than default in the base element.
|
595 | */
|
596 | function shouldInheritListType(baseItem, itemToChange, attributeStrategy) {
|
597 | if (!baseItem) {
|
598 | return false;
|
599 | }
|
600 | const baseListAttribute = baseItem.getAttribute(attributeStrategy.attributeName);
|
601 | if (!baseListAttribute) {
|
602 | return false;
|
603 | }
|
604 | if (baseListAttribute == attributeStrategy.defaultValue) {
|
605 | return false;
|
606 | }
|
607 | if (baseItem.getAttribute('listType') !== itemToChange.getAttribute('listType')) {
|
608 | return false;
|
609 | }
|
610 | return true;
|
611 | }
|
612 | /**
|
613 | * Checks whether the `listStyle`, `listReversed` and `listStart` attributes
|
614 | * should be copied from previous list item.
|
615 | *
|
616 | * The attribute should be copied if there's a mismatch of styles of the pasted list into a nested list.
|
617 | * Top-level lists are not normalized as we allow side-by-side list of different types.
|
618 | */
|
619 | function shouldInheritListTypeFromPreviousItem(previousItem, itemToChange, attributeName) {
|
620 | if (!previousItem || !previousItem.is('element', 'listItem')) {
|
621 | return false;
|
622 | }
|
623 | if (itemToChange.getAttribute('listType') !== previousItem.getAttribute('listType')) {
|
624 | return false;
|
625 | }
|
626 | const previousItemIndent = previousItem.getAttribute('listIndent');
|
627 | if (previousItemIndent < 1 || previousItemIndent !== itemToChange.getAttribute('listIndent')) {
|
628 | return false;
|
629 | }
|
630 | const previousItemListAttribute = previousItem.getAttribute(attributeName);
|
631 | if (!previousItemListAttribute || previousItemListAttribute === itemToChange.getAttribute(attributeName)) {
|
632 | return false;
|
633 | }
|
634 | return true;
|
635 | }
|
636 | /**
|
637 | * Removes the `listStyle`, `listReversed` and `listStart` attributes from "todo" list items.
|
638 | */
|
639 | function removeListItemAttributesFromTodoList(editor) {
|
640 | return (writer) => {
|
641 | const todoListItems = getChangedListItems(editor.model.document.differ.getChanges())
|
642 | .filter(item => {
|
643 | // Handle the todo lists only. The rest is handled in another post-fixer.
|
644 | return item.getAttribute('listType') === 'todo' && (item.hasAttribute('listStyle') ||
|
645 | item.hasAttribute('listReversed') ||
|
646 | item.hasAttribute('listStart'));
|
647 | });
|
648 | if (!todoListItems.length) {
|
649 | return false;
|
650 | }
|
651 | for (const item of todoListItems) {
|
652 | writer.removeAttribute('listStyle', item);
|
653 | writer.removeAttribute('listReversed', item);
|
654 | writer.removeAttribute('listStart', item);
|
655 | }
|
656 | return true;
|
657 | };
|
658 | }
|
659 | /**
|
660 | * Restores the `listStyle` attribute after changing the list type.
|
661 | */
|
662 | function restoreDefaultListStyle(editor) {
|
663 | return (evt, changedItems) => {
|
664 | changedItems = changedItems.filter(item => item.is('element', 'listItem'));
|
665 | editor.model.change(writer => {
|
666 | for (const item of changedItems) {
|
667 | // Remove the attribute. Post-fixer will restore the proper value.
|
668 | writer.removeAttribute('listStyle', item);
|
669 | }
|
670 | });
|
671 | };
|
672 | }
|
673 | /**
|
674 | * Returns the `listItem` that was inserted or changed.
|
675 | *
|
676 | * @param changes The changes list returned by the differ.
|
677 | */
|
678 | function getChangedListItems(changes) {
|
679 | const items = [];
|
680 | for (const change of changes) {
|
681 | const item = getItemFromChange(change);
|
682 | if (item && item.is('element', 'listItem')) {
|
683 | items.push(item);
|
684 | }
|
685 | }
|
686 | return items;
|
687 | }
|
688 | function getItemFromChange(change) {
|
689 | if (change.type === 'attribute') {
|
690 | return change.range.start.nodeAfter;
|
691 | }
|
692 | if (change.type === 'insert') {
|
693 | return change.position.nodeAfter;
|
694 | }
|
695 | return null;
|
696 | }
|