UNPKG

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