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/documentlist/documentlistediting
|
7 | */
|
8 | import { Plugin } from 'ckeditor5/src/core';
|
9 | import { Delete } from 'ckeditor5/src/typing';
|
10 | import { Enter } from 'ckeditor5/src/enter';
|
11 | import { CKEditorError } from 'ckeditor5/src/utils';
|
12 | import DocumentListIndentCommand from './documentlistindentcommand';
|
13 | import DocumentListCommand from './documentlistcommand';
|
14 | import DocumentListMergeCommand from './documentlistmergecommand';
|
15 | import DocumentListSplitCommand from './documentlistsplitcommand';
|
16 | import DocumentListUtils from './documentlistutils';
|
17 | import { bogusParagraphCreator, listItemDowncastConverter, listItemUpcastConverter, listUpcastCleanList, reconvertItemsOnDataChange } from './converters';
|
18 | import { findAndAddListHeadToMap, fixListIndents, fixListItemIds } from './utils/postfixers';
|
19 | import { getAllListItemBlocks, isFirstBlockOfListItem, isLastBlockOfListItem, isSingleListItem, getSelectedBlockObject, isListItemBlock, removeListAttributes } from './utils/model';
|
20 | import { getViewElementIdForListType, getViewElementNameForListType } from './utils/view';
|
21 | import ListWalker, { iterateSiblingListBlocks, ListBlocksIterable } from './utils/listwalker';
|
22 | import '../../theme/documentlist.css';
|
23 | import '../../theme/list.css';
|
24 | /**
|
25 | * A list of base list model attributes.
|
26 | */
|
27 | const LIST_BASE_ATTRIBUTES = ['listType', 'listIndent', 'listItemId'];
|
28 | /**
|
29 | * The editing part of the document-list feature. It handles creating, editing and removing lists and list items.
|
30 | */
|
31 | export default class DocumentListEditing extends Plugin {
|
32 | constructor() {
|
33 | super(...arguments);
|
34 | /**
|
35 | * The list of registered downcast strategies.
|
36 | */
|
37 | this._downcastStrategies = [];
|
38 | }
|
39 | /**
|
40 | * @inheritDoc
|
41 | */
|
42 | static get pluginName() {
|
43 | return 'DocumentListEditing';
|
44 | }
|
45 | /**
|
46 | * @inheritDoc
|
47 | */
|
48 | static get requires() {
|
49 | return [Enter, Delete, DocumentListUtils];
|
50 | }
|
51 | /**
|
52 | * @inheritDoc
|
53 | */
|
54 | init() {
|
55 | const editor = this.editor;
|
56 | const model = editor.model;
|
57 | if (editor.plugins.has('ListEditing')) {
|
58 | /**
|
59 | * The `DocumentList` feature can not be loaded together with the `List` plugin.
|
60 | *
|
61 | * @error document-list-feature-conflict
|
62 | * @param conflictPlugin Name of the plugin.
|
63 | */
|
64 | throw new CKEditorError('document-list-feature-conflict', this, { conflictPlugin: 'ListEditing' });
|
65 | }
|
66 | model.schema.extend('$container', { allowAttributes: LIST_BASE_ATTRIBUTES });
|
67 | model.schema.extend('$block', { allowAttributes: LIST_BASE_ATTRIBUTES });
|
68 | model.schema.extend('$blockObject', { allowAttributes: LIST_BASE_ATTRIBUTES });
|
69 | for (const attribute of LIST_BASE_ATTRIBUTES) {
|
70 | model.schema.setAttributeProperties(attribute, {
|
71 | copyOnReplace: true
|
72 | });
|
73 | }
|
74 | // Register commands.
|
75 | editor.commands.add('numberedList', new DocumentListCommand(editor, 'numbered'));
|
76 | editor.commands.add('bulletedList', new DocumentListCommand(editor, 'bulleted'));
|
77 | editor.commands.add('indentList', new DocumentListIndentCommand(editor, 'forward'));
|
78 | editor.commands.add('outdentList', new DocumentListIndentCommand(editor, 'backward'));
|
79 | editor.commands.add('mergeListItemBackward', new DocumentListMergeCommand(editor, 'backward'));
|
80 | editor.commands.add('mergeListItemForward', new DocumentListMergeCommand(editor, 'forward'));
|
81 | editor.commands.add('splitListItemBefore', new DocumentListSplitCommand(editor, 'before'));
|
82 | editor.commands.add('splitListItemAfter', new DocumentListSplitCommand(editor, 'after'));
|
83 | this._setupDeleteIntegration();
|
84 | this._setupEnterIntegration();
|
85 | this._setupTabIntegration();
|
86 | this._setupClipboardIntegration();
|
87 | }
|
88 | /**
|
89 | * @inheritDoc
|
90 | */
|
91 | afterInit() {
|
92 | const editor = this.editor;
|
93 | const commands = editor.commands;
|
94 | const indent = commands.get('indent');
|
95 | const outdent = commands.get('outdent');
|
96 | if (indent) {
|
97 | // Priority is high due to integration with `IndentBlock` plugin. We want to indent list first and if it's not possible
|
98 | // user can indent content with `IndentBlock` plugin.
|
99 | indent.registerChildCommand(commands.get('indentList'), { priority: 'high' });
|
100 | }
|
101 | if (outdent) {
|
102 | // Priority is lowest due to integration with `IndentBlock` and `IndentCode` plugins.
|
103 | // First we want to allow user to outdent all indendations from other features then he can oudent list item.
|
104 | outdent.registerChildCommand(commands.get('outdentList'), { priority: 'lowest' });
|
105 | }
|
106 | // Register conversion and model post-fixer after other plugins had a chance to register their attribute strategies.
|
107 | this._setupModelPostFixing();
|
108 | this._setupConversion();
|
109 | }
|
110 | /**
|
111 | * Registers a downcast strategy.
|
112 | *
|
113 | * **Note**: Strategies must be registered in the `Plugin#init()` phase so that it can be applied
|
114 | * in the `DocumentListEditing#afterInit()`.
|
115 | *
|
116 | * @param strategy The downcast strategy to register.
|
117 | */
|
118 | registerDowncastStrategy(strategy) {
|
119 | this._downcastStrategies.push(strategy);
|
120 | }
|
121 | /**
|
122 | * Returns list of model attribute names that should affect downcast conversion.
|
123 | */
|
124 | _getListAttributeNames() {
|
125 | return [
|
126 | ...LIST_BASE_ATTRIBUTES,
|
127 | ...this._downcastStrategies.map(strategy => strategy.attributeName)
|
128 | ];
|
129 | }
|
130 | /**
|
131 | * Attaches the listener to the {@link module:engine/view/document~Document#event:delete} event and handles backspace/delete
|
132 | * keys in and around document lists.
|
133 | */
|
134 | _setupDeleteIntegration() {
|
135 | const editor = this.editor;
|
136 | const mergeBackwardCommand = editor.commands.get('mergeListItemBackward');
|
137 | const mergeForwardCommand = editor.commands.get('mergeListItemForward');
|
138 | this.listenTo(editor.editing.view.document, 'delete', (evt, data) => {
|
139 | const selection = editor.model.document.selection;
|
140 | // Let the Widget plugin take care of block widgets while deleting (https://github.com/ckeditor/ckeditor5/issues/11346).
|
141 | if (getSelectedBlockObject(editor.model)) {
|
142 | return;
|
143 | }
|
144 | editor.model.change(() => {
|
145 | const firstPosition = selection.getFirstPosition();
|
146 | if (selection.isCollapsed && data.direction == 'backward') {
|
147 | if (!firstPosition.isAtStart) {
|
148 | return;
|
149 | }
|
150 | const positionParent = firstPosition.parent;
|
151 | if (!isListItemBlock(positionParent)) {
|
152 | return;
|
153 | }
|
154 | const previousBlock = ListWalker.first(positionParent, {
|
155 | sameAttributes: 'listType',
|
156 | sameIndent: true
|
157 | });
|
158 | // Outdent the first block of a first list item.
|
159 | if (!previousBlock && positionParent.getAttribute('listIndent') === 0) {
|
160 | if (!isLastBlockOfListItem(positionParent)) {
|
161 | editor.execute('splitListItemAfter');
|
162 | }
|
163 | editor.execute('outdentList');
|
164 | }
|
165 | // Merge block with previous one (on the block level or on the content level).
|
166 | else {
|
167 | if (!mergeBackwardCommand.isEnabled) {
|
168 | return;
|
169 | }
|
170 | mergeBackwardCommand.execute({
|
171 | shouldMergeOnBlocksContentLevel: shouldMergeOnBlocksContentLevel(editor.model, 'backward')
|
172 | });
|
173 | }
|
174 | data.preventDefault();
|
175 | evt.stop();
|
176 | }
|
177 | // Non-collapsed selection or forward delete.
|
178 | else {
|
179 | // Collapsed selection should trigger forward merging only if at the end of a block.
|
180 | if (selection.isCollapsed && !selection.getLastPosition().isAtEnd) {
|
181 | return;
|
182 | }
|
183 | if (!mergeForwardCommand.isEnabled) {
|
184 | return;
|
185 | }
|
186 | mergeForwardCommand.execute({
|
187 | shouldMergeOnBlocksContentLevel: shouldMergeOnBlocksContentLevel(editor.model, 'forward')
|
188 | });
|
189 | data.preventDefault();
|
190 | evt.stop();
|
191 | }
|
192 | });
|
193 | }, { context: 'li' });
|
194 | }
|
195 | /**
|
196 | * Attaches a listener to the {@link module:engine/view/document~Document#event:enter} event and handles enter key press
|
197 | * in document lists.
|
198 | */
|
199 | _setupEnterIntegration() {
|
200 | const editor = this.editor;
|
201 | const model = editor.model;
|
202 | const commands = editor.commands;
|
203 | const enterCommand = commands.get('enter');
|
204 | // Overwrite the default Enter key behavior: outdent or split the list in certain cases.
|
205 | this.listenTo(editor.editing.view.document, 'enter', (evt, data) => {
|
206 | const doc = model.document;
|
207 | const positionParent = doc.selection.getFirstPosition().parent;
|
208 | if (doc.selection.isCollapsed &&
|
209 | isListItemBlock(positionParent) &&
|
210 | positionParent.isEmpty &&
|
211 | !data.isSoft) {
|
212 | const isFirstBlock = isFirstBlockOfListItem(positionParent);
|
213 | const isLastBlock = isLastBlockOfListItem(positionParent);
|
214 | // * a → * a
|
215 | // * [] → []
|
216 | if (isFirstBlock && isLastBlock) {
|
217 | editor.execute('outdentList');
|
218 | data.preventDefault();
|
219 | evt.stop();
|
220 | }
|
221 | // * [] → * []
|
222 | // a → * a
|
223 | else if (isFirstBlock && !isLastBlock) {
|
224 | editor.execute('splitListItemAfter');
|
225 | data.preventDefault();
|
226 | evt.stop();
|
227 | }
|
228 | // * a → * a
|
229 | // [] → * []
|
230 | else if (isLastBlock) {
|
231 | editor.execute('splitListItemBefore');
|
232 | data.preventDefault();
|
233 | evt.stop();
|
234 | }
|
235 | }
|
236 | }, { context: 'li' });
|
237 | // In some cases, after the default block splitting, we want to modify the new block to become a new list item
|
238 | // instead of an additional block in the same list item.
|
239 | this.listenTo(enterCommand, 'afterExecute', () => {
|
240 | const splitCommand = commands.get('splitListItemBefore');
|
241 | // The command has not refreshed because the change block related to EnterCommand#execute() is not over yet.
|
242 | // Let's keep it up to date and take advantage of DocumentListSplitCommand#isEnabled.
|
243 | splitCommand.refresh();
|
244 | if (!splitCommand.isEnabled) {
|
245 | return;
|
246 | }
|
247 | const doc = editor.model.document;
|
248 | const positionParent = doc.selection.getLastPosition().parent;
|
249 | const listItemBlocks = getAllListItemBlocks(positionParent);
|
250 | // Keep in mind this split happens after the default enter handler was executed. For instance:
|
251 | //
|
252 | // │ Initial state │ After default enter │ Here in #afterExecute │
|
253 | // ├───────────────────────────┼───────────────────────────┼───────────────────────────┤
|
254 | // │ * a[] │ * a │ * a │
|
255 | // │ │ [] │ * [] │
|
256 | if (listItemBlocks.length === 2) {
|
257 | splitCommand.execute();
|
258 | }
|
259 | });
|
260 | }
|
261 | /**
|
262 | * Attaches a listener to the {@link module:engine/view/document~Document#event:tab} event and handles tab key and tab+shift keys
|
263 | * presses in document lists.
|
264 | */
|
265 | _setupTabIntegration() {
|
266 | const editor = this.editor;
|
267 | this.listenTo(editor.editing.view.document, 'tab', (evt, data) => {
|
268 | const commandName = data.shiftKey ? 'outdentList' : 'indentList';
|
269 | const command = this.editor.commands.get(commandName);
|
270 | if (command.isEnabled) {
|
271 | editor.execute(commandName);
|
272 | data.stopPropagation();
|
273 | data.preventDefault();
|
274 | evt.stop();
|
275 | }
|
276 | }, { context: 'li' });
|
277 | }
|
278 | /**
|
279 | * Registers the conversion helpers for the document-list feature.
|
280 | */
|
281 | _setupConversion() {
|
282 | const editor = this.editor;
|
283 | const model = editor.model;
|
284 | const attributeNames = this._getListAttributeNames();
|
285 | editor.conversion.for('upcast')
|
286 | .elementToElement({ view: 'li', model: 'paragraph' })
|
287 | .add(dispatcher => {
|
288 | dispatcher.on('element:li', listItemUpcastConverter());
|
289 | dispatcher.on('element:ul', listUpcastCleanList(), { priority: 'high' });
|
290 | dispatcher.on('element:ol', listUpcastCleanList(), { priority: 'high' });
|
291 | });
|
292 | editor.conversion.for('editingDowncast')
|
293 | .elementToElement({
|
294 | model: 'paragraph',
|
295 | view: bogusParagraphCreator(attributeNames),
|
296 | converterPriority: 'high'
|
297 | });
|
298 | editor.conversion.for('dataDowncast')
|
299 | .elementToElement({
|
300 | model: 'paragraph',
|
301 | view: bogusParagraphCreator(attributeNames, { dataPipeline: true }),
|
302 | converterPriority: 'high'
|
303 | });
|
304 | editor.conversion.for('downcast')
|
305 | .add(dispatcher => {
|
306 | dispatcher.on('attribute', listItemDowncastConverter(attributeNames, this._downcastStrategies, model));
|
307 | });
|
308 | this.listenTo(model.document, 'change:data', reconvertItemsOnDataChange(model, editor.editing, attributeNames, this), { priority: 'high' });
|
309 | // For LI verify if an ID of the attribute element is correct.
|
310 | this.on('checkAttributes:item', (evt, { viewElement, modelAttributes }) => {
|
311 | if (viewElement.id != modelAttributes.listItemId) {
|
312 | evt.return = true;
|
313 | evt.stop();
|
314 | }
|
315 | });
|
316 | // For UL and OL check if the name and ID of element is correct.
|
317 | this.on('checkAttributes:list', (evt, { viewElement, modelAttributes }) => {
|
318 | if (viewElement.name != getViewElementNameForListType(modelAttributes.listType) ||
|
319 | viewElement.id != getViewElementIdForListType(modelAttributes.listType, modelAttributes.listIndent)) {
|
320 | evt.return = true;
|
321 | evt.stop();
|
322 | }
|
323 | });
|
324 | }
|
325 | /**
|
326 | * Registers model post-fixers.
|
327 | */
|
328 | _setupModelPostFixing() {
|
329 | const model = this.editor.model;
|
330 | const attributeNames = this._getListAttributeNames();
|
331 | // Register list fixing.
|
332 | // First the low level handler.
|
333 | model.document.registerPostFixer(writer => modelChangePostFixer(model, writer, attributeNames, this));
|
334 | // Then the callbacks for the specific lists.
|
335 | // The indentation fixing must be the first one...
|
336 | this.on('postFixer', (evt, { listNodes, writer }) => {
|
337 | evt.return = fixListIndents(listNodes, writer) || evt.return;
|
338 | }, { priority: 'high' });
|
339 | // ...then the item ids... and after that other fixers that rely on the correct indentation and ids.
|
340 | this.on('postFixer', (evt, { listNodes, writer, seenIds }) => {
|
341 | evt.return = fixListItemIds(listNodes, seenIds, writer) || evt.return;
|
342 | }, { priority: 'high' });
|
343 | }
|
344 | /**
|
345 | * Integrates the feature with the clipboard via {@link module:engine/model/model~Model#insertContent} and
|
346 | * {@link module:engine/model/model~Model#getSelectedContent}.
|
347 | */
|
348 | _setupClipboardIntegration() {
|
349 | const model = this.editor.model;
|
350 | this.listenTo(model, 'insertContent', createModelIndentPasteFixer(model), { priority: 'high' });
|
351 | // To enhance the UX, the editor should not copy list attributes to the clipboard if the selection
|
352 | // started and ended in the same list item.
|
353 | //
|
354 | // If the selection was enclosed in a single list item, there is a good chance the user did not want it
|
355 | // copied as a list item but plain blocks.
|
356 | //
|
357 | // This avoids pasting orphaned list items instead of paragraphs, for instance, straight into the root.
|
358 | //
|
359 | // ┌─────────────────────┬───────────────────┐
|
360 | // │ Selection │ Clipboard content │
|
361 | // ├─────────────────────┼───────────────────┤
|
362 | // │ [* <Widget />] │ <Widget /> │
|
363 | // ├─────────────────────┼───────────────────┤
|
364 | // │ [* Foo] │ Foo │
|
365 | // ├─────────────────────┼───────────────────┤
|
366 | // │ * Foo [bar] baz │ bar │
|
367 | // ├─────────────────────┼───────────────────┤
|
368 | // │ * Fo[o │ o │
|
369 | // │ ba]r │ ba │
|
370 | // ├─────────────────────┼───────────────────┤
|
371 | // │ * Fo[o │ * o │
|
372 | // │ * ba]r │ * ba │
|
373 | // ├─────────────────────┼───────────────────┤
|
374 | // │ [* Foo │ * Foo │
|
375 | // │ * bar] │ * bar │
|
376 | // └─────────────────────┴───────────────────┘
|
377 | //
|
378 | // See https://github.com/ckeditor/ckeditor5/issues/11608.
|
379 | this.listenTo(model, 'getSelectedContent', (evt, [selection]) => {
|
380 | const isSingleListItemSelected = isSingleListItem(Array.from(selection.getSelectedBlocks()));
|
381 | if (isSingleListItemSelected) {
|
382 | model.change(writer => removeListAttributes(Array.from(evt.return.getChildren()), writer));
|
383 | }
|
384 | });
|
385 | }
|
386 | }
|
387 | /**
|
388 | * Post-fixer that reacts to changes on document and fixes incorrect model states (invalid `listItemId` and `listIndent` values).
|
389 | *
|
390 | * In the example below, there is a correct list structure.
|
391 | * Then the middle element is removed so the list structure will become incorrect:
|
392 | *
|
393 | * ```xml
|
394 | * <paragraph listType="bulleted" listItemId="a" listIndent=0>Item 1</paragraph>
|
395 | * <paragraph listType="bulleted" listItemId="b" listIndent=1>Item 2</paragraph> <--- this is removed.
|
396 | * <paragraph listType="bulleted" listItemId="c" listIndent=2>Item 3</paragraph>
|
397 | * ```
|
398 | *
|
399 | * The list structure after the middle element is removed:
|
400 | *
|
401 | * ```xml
|
402 | * <paragraph listType="bulleted" listItemId="a" listIndent=0>Item 1</paragraph>
|
403 | * <paragraph listType="bulleted" listItemId="c" listIndent=2>Item 3</paragraph>
|
404 | * ```
|
405 | *
|
406 | * Should become:
|
407 | *
|
408 | * ```xml
|
409 | * <paragraph listType="bulleted" listItemId="a" listIndent=0>Item 1</paragraph>
|
410 | * <paragraph listType="bulleted" listItemId="c" listIndent=1>Item 3</paragraph> <--- note that indent got post-fixed.
|
411 | * ```
|
412 | *
|
413 | * @param model The data model.
|
414 | * @param writer The writer to do changes with.
|
415 | * @param attributeNames The list of all model list attributes (including registered strategies).
|
416 | * @param documentListEditing The document list editing plugin.
|
417 | * @returns `true` if any change has been applied, `false` otherwise.
|
418 | */
|
419 | function modelChangePostFixer(model, writer, attributeNames, documentListEditing) {
|
420 | const changes = model.document.differ.getChanges();
|
421 | const itemToListHead = new Map();
|
422 | let applied = false;
|
423 | for (const entry of changes) {
|
424 | if (entry.type == 'insert' && entry.name != '$text') {
|
425 | const item = entry.position.nodeAfter;
|
426 | // Remove attributes in case of renamed element.
|
427 | if (!model.schema.checkAttribute(item, 'listItemId')) {
|
428 | for (const attributeName of Array.from(item.getAttributeKeys())) {
|
429 | if (attributeNames.includes(attributeName)) {
|
430 | writer.removeAttribute(attributeName, item);
|
431 | applied = true;
|
432 | }
|
433 | }
|
434 | }
|
435 | findAndAddListHeadToMap(entry.position, itemToListHead);
|
436 | // Insert of a non-list item - check if there is a list after it.
|
437 | if (!entry.attributes.has('listItemId')) {
|
438 | findAndAddListHeadToMap(entry.position.getShiftedBy(entry.length), itemToListHead);
|
439 | }
|
440 | // Check if there is no nested list.
|
441 | for (const { item: innerItem, previousPosition } of model.createRangeIn(item)) {
|
442 | if (isListItemBlock(innerItem)) {
|
443 | findAndAddListHeadToMap(previousPosition, itemToListHead);
|
444 | }
|
445 | }
|
446 | }
|
447 | // Removed list item or block adjacent to a list.
|
448 | else if (entry.type == 'remove') {
|
449 | findAndAddListHeadToMap(entry.position, itemToListHead);
|
450 | }
|
451 | // Changed list item indent or type.
|
452 | else if (entry.type == 'attribute' && attributeNames.includes(entry.attributeKey)) {
|
453 | findAndAddListHeadToMap(entry.range.start, itemToListHead);
|
454 | if (entry.attributeNewValue === null) {
|
455 | findAndAddListHeadToMap(entry.range.start.getShiftedBy(1), itemToListHead);
|
456 | }
|
457 | }
|
458 | }
|
459 | // Make sure that IDs are not shared by split list.
|
460 | const seenIds = new Set();
|
461 | for (const listHead of itemToListHead.values()) {
|
462 | applied = documentListEditing.fire('postFixer', {
|
463 | listNodes: new ListBlocksIterable(listHead),
|
464 | listHead,
|
465 | writer,
|
466 | seenIds
|
467 | }) || applied;
|
468 | }
|
469 | return applied;
|
470 | }
|
471 | /**
|
472 | * A fixer for pasted content that includes list items.
|
473 | *
|
474 | * It fixes indentation of pasted list items so the pasted items match correctly to the context they are pasted into.
|
475 | *
|
476 | * Example:
|
477 | *
|
478 | * ```xml
|
479 | * <paragraph listType="bulleted" listItemId="a" listIndent=0>A</paragraph>
|
480 | * <paragraph listType="bulleted" listItemId="b" listIndent=1>B^</paragraph>
|
481 | * // At ^ paste: <paragraph listType="bulleted" listItemId="x" listIndent=4>X</paragraph>
|
482 | * // <paragraph listType="bulleted" listItemId="y" listIndent=5>Y</paragraph>
|
483 | * <paragraph listType="bulleted" listItemId="c" listIndent=2>C</paragraph>
|
484 | * ```
|
485 | *
|
486 | * Should become:
|
487 | *
|
488 | * ```xml
|
489 | * <paragraph listType="bulleted" listItemId="a" listIndent=0>A</paragraph>
|
490 | * <paragraph listType="bulleted" listItemId="b" listIndent=1>BX</paragraph>
|
491 | * <paragraph listType="bulleted" listItemId="y" listIndent=2>Y/paragraph>
|
492 | * <paragraph listType="bulleted" listItemId="c" listIndent=2>C</paragraph>
|
493 | * ```
|
494 | */
|
495 | function createModelIndentPasteFixer(model) {
|
496 | return (evt, [content, selectable]) => {
|
497 | // Check whether inserted content starts from a `listItem`. If it does not, it means that there are some other
|
498 | // elements before it and there is no need to fix indents, because even if we insert that content into a list,
|
499 | // that list will be broken.
|
500 | // Note: we also need to handle singular elements because inserting item with indent 0 into 0,1,[],2
|
501 | // would create incorrect model.
|
502 | const item = content.is('documentFragment') ? content.getChild(0) : content;
|
503 | if (!isListItemBlock(item)) {
|
504 | return;
|
505 | }
|
506 | let selection;
|
507 | if (!selectable) {
|
508 | selection = model.document.selection;
|
509 | }
|
510 | else {
|
511 | selection = model.createSelection(selectable);
|
512 | }
|
513 | // Get a reference list item. Inserted list items will be fixed according to that item.
|
514 | const pos = selection.getFirstPosition();
|
515 | let refItem = null;
|
516 | if (isListItemBlock(pos.parent)) {
|
517 | refItem = pos.parent;
|
518 | }
|
519 | else if (isListItemBlock(pos.nodeBefore)) {
|
520 | refItem = pos.nodeBefore;
|
521 | }
|
522 | // If there is `refItem` it means that we do insert list items into an existing list.
|
523 | if (!refItem) {
|
524 | return;
|
525 | }
|
526 | // First list item in `data` has indent equal to 0 (it is a first list item). It should have indent equal
|
527 | // to the indent of reference item. We have to fix the first item and all of it's children and following siblings.
|
528 | // Indent of all those items has to be adjusted to reference item.
|
529 | const indentChange = refItem.getAttribute('listIndent') - item.getAttribute('listIndent');
|
530 | // Fix only if there is anything to fix.
|
531 | if (indentChange <= 0) {
|
532 | return;
|
533 | }
|
534 | model.change(writer => {
|
535 | // Adjust indent of all "first" list items in inserted data.
|
536 | for (const { node } of iterateSiblingListBlocks(item, 'forward')) {
|
537 | writer.setAttribute('listIndent', node.getAttribute('listIndent') + indentChange, node);
|
538 | }
|
539 | });
|
540 | };
|
541 | }
|
542 | /**
|
543 | * Decides whether the merge should be accompanied by the model's `deleteContent()`, for instance, to get rid of the inline
|
544 | * content in the selection or take advantage of the heuristics in `deleteContent()` that helps convert lists into paragraphs
|
545 | * in certain cases.
|
546 | */
|
547 | function shouldMergeOnBlocksContentLevel(model, direction) {
|
548 | const selection = model.document.selection;
|
549 | if (!selection.isCollapsed) {
|
550 | return !getSelectedBlockObject(model);
|
551 | }
|
552 | if (direction === 'forward') {
|
553 | return true;
|
554 | }
|
555 | const firstPosition = selection.getFirstPosition();
|
556 | const positionParent = firstPosition.parent;
|
557 | const previousSibling = positionParent.previousSibling;
|
558 | if (model.schema.isObject(previousSibling)) {
|
559 | return false;
|
560 | }
|
561 | if (previousSibling.isEmpty) {
|
562 | return true;
|
563 | }
|
564 | return isSingleListItem([positionParent, previousSibling]);
|
565 | }
|