1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 | import { Plugin } from 'ckeditor5/src/core';
|
11 | import { Enter } from 'ckeditor5/src/enter';
|
12 | import { Delete } from 'ckeditor5/src/typing';
|
13 | import { CKEditorError } from 'ckeditor5/src/utils';
|
14 |
|
15 | import DocumentListIndentCommand from './documentlistindentcommand';
|
16 | import DocumentListCommand from './documentlistcommand';
|
17 | import DocumentListMergeCommand from './documentlistmergecommand';
|
18 | import DocumentListSplitCommand from './documentlistsplitcommand';
|
19 | import {
|
20 | bogusParagraphCreator,
|
21 | listItemDowncastConverter,
|
22 | listItemUpcastConverter,
|
23 | listUpcastCleanList,
|
24 | reconvertItemsOnDataChange
|
25 | } from './converters';
|
26 | import {
|
27 | findAndAddListHeadToMap,
|
28 | fixListIndents,
|
29 | fixListItemIds
|
30 | } from './utils/postfixers';
|
31 | import {
|
32 | getAllListItemBlocks,
|
33 | isFirstBlockOfListItem,
|
34 | isLastBlockOfListItem,
|
35 | isSingleListItem,
|
36 | getSelectedBlockObject,
|
37 | isListItemBlock,
|
38 | removeListAttributes
|
39 | } from './utils/model';
|
40 | import {
|
41 | getViewElementIdForListType,
|
42 | getViewElementNameForListType
|
43 | } from './utils/view';
|
44 | import ListWalker, {
|
45 | iterateSiblingListBlocks,
|
46 | ListBlocksIterable
|
47 | } from './utils/listwalker';
|
48 |
|
49 | import '../../theme/documentlist.css';
|
50 |
|
51 |
|
52 |
|
53 |
|
54 |
|
55 |
|
56 | const LIST_BASE_ATTRIBUTES = [ 'listType', 'listIndent', 'listItemId' ];
|
57 |
|
58 |
|
59 |
|
60 |
|
61 |
|
62 |
|
63 | export default class DocumentListEditing extends Plugin {
|
64 | |
65 |
|
66 |
|
67 | static get pluginName() {
|
68 | return 'DocumentListEditing';
|
69 | }
|
70 |
|
71 | |
72 |
|
73 |
|
74 | static get requires() {
|
75 | return [ Enter, Delete ];
|
76 | }
|
77 |
|
78 | |
79 |
|
80 |
|
81 | constructor( editor ) {
|
82 | super( editor );
|
83 |
|
84 | |
85 |
|
86 |
|
87 |
|
88 |
|
89 |
|
90 | this._downcastStrategies = [];
|
91 | }
|
92 |
|
93 | |
94 |
|
95 |
|
96 | init() {
|
97 | const editor = this.editor;
|
98 | const model = editor.model;
|
99 |
|
100 | if ( editor.plugins.has( 'ListEditing' ) ) {
|
101 | |
102 |
|
103 |
|
104 |
|
105 |
|
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 |
|
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 |
|
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 |
|
150 |
|
151 | indent.registerChildCommand( commands.get( 'indentList' ), { priority: 'high' } );
|
152 | }
|
153 |
|
154 | if ( outdent ) {
|
155 |
|
156 |
|
157 | outdent.registerChildCommand( commands.get( 'outdentList' ), { priority: 'lowest' } );
|
158 | }
|
159 |
|
160 |
|
161 | this._setupModelPostFixing();
|
162 | this._setupConversion();
|
163 | }
|
164 |
|
165 | |
166 |
|
167 |
|
168 |
|
169 |
|
170 |
|
171 |
|
172 |
|
173 | registerDowncastStrategy( strategy ) {
|
174 | this._downcastStrategies.push( strategy );
|
175 | }
|
176 |
|
177 | |
178 |
|
179 |
|
180 |
|
181 |
|
182 | _getListAttributeNames() {
|
183 | return [
|
184 | ...LIST_BASE_ATTRIBUTES,
|
185 | ...this._downcastStrategies.map( strategy => strategy.attributeName )
|
186 | ];
|
187 | }
|
188 |
|
189 | |
190 |
|
191 |
|
192 |
|
193 |
|
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 |
|
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 |
|
228 | if ( !previousBlock && positionParent.getAttribute( 'listIndent' ) === 0 ) {
|
229 | if ( !isLastBlockOfListItem( positionParent ) ) {
|
230 | editor.execute( 'splitListItemAfter' );
|
231 | }
|
232 |
|
233 | editor.execute( 'outdentList' );
|
234 | }
|
235 |
|
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 |
|
250 | else {
|
251 |
|
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 |
|
273 |
|
274 |
|
275 |
|
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 |
|
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 |
|
298 |
|
299 | if ( isFirstBlock && isLastBlock ) {
|
300 | editor.execute( 'outdentList' );
|
301 |
|
302 | data.preventDefault();
|
303 | evt.stop();
|
304 | }
|
305 |
|
306 |
|
307 | else if ( isFirstBlock && !isLastBlock ) {
|
308 | editor.execute( 'splitListItemAfter' );
|
309 |
|
310 | data.preventDefault();
|
311 | evt.stop();
|
312 | }
|
313 |
|
314 |
|
315 | else if ( isLastBlock ) {
|
316 | editor.execute( 'splitListItemBefore' );
|
317 |
|
318 | data.preventDefault();
|
319 | evt.stop();
|
320 | }
|
321 | }
|
322 | }, { context: 'li' } );
|
323 |
|
324 |
|
325 |
|
326 | this.listenTo( enterCommand, 'afterExecute', () => {
|
327 | const splitCommand = commands.get( 'splitListItemBefore' );
|
328 |
|
329 |
|
330 |
|
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 |
|
342 |
|
343 |
|
344 |
|
345 |
|
346 |
|
347 | if ( listItemBlocks.length === 2 ) {
|
348 | splitCommand.execute();
|
349 | }
|
350 | } );
|
351 | }
|
352 |
|
353 | |
354 |
|
355 |
|
356 |
|
357 |
|
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 |
|
378 |
|
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 |
|
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 |
|
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 |
|
436 |
|
437 |
|
438 |
|
439 | _setupModelPostFixing() {
|
440 | const model = this.editor.model;
|
441 | const attributeNames = this._getListAttributeNames();
|
442 |
|
443 |
|
444 |
|
445 | model.document.registerPostFixer( writer => modelChangePostFixer( model, writer, attributeNames, this ) );
|
446 |
|
447 |
|
448 |
|
449 | this.on( 'postFixer', ( evt, { listNodes, writer } ) => {
|
450 | evt.return = fixListIndents( listNodes, writer ) || evt.return;
|
451 | }, { priority: 'high' } );
|
452 |
|
453 |
|
454 | this.on( 'postFixer', ( evt, { listNodes, writer, seenIds } ) => {
|
455 | evt.return = fixListItemIds( listNodes, seenIds, writer ) || evt.return;
|
456 | }, { priority: 'high' } );
|
457 | }
|
458 |
|
459 | |
460 |
|
461 |
|
462 |
|
463 |
|
464 |
|
465 | _setupClipboardIntegration() {
|
466 | const model = this.editor.model;
|
467 |
|
468 | this.listenTo( model, 'insertContent', createModelIndentPasteFixer( model ), { priority: 'high' } );
|
469 |
|
470 |
|
471 |
|
472 |
|
473 |
|
474 |
|
475 |
|
476 |
|
477 |
|
478 |
|
479 |
|
480 |
|
481 |
|
482 |
|
483 |
|
484 |
|
485 |
|
486 |
|
487 |
|
488 |
|
489 |
|
490 |
|
491 |
|
492 |
|
493 |
|
494 |
|
495 |
|
496 |
|
497 |
|
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 |
|
510 |
|
511 |
|
512 |
|
513 |
|
514 |
|
515 |
|
516 |
|
517 |
|
518 |
|
519 |
|
520 |
|
521 |
|
522 |
|
523 |
|
524 |
|
525 |
|
526 |
|
527 |
|
528 |
|
529 |
|
530 |
|
531 |
|
532 |
|
533 |
|
534 |
|
535 |
|
536 |
|
537 |
|
538 |
|
539 | function 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 |
|
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 |
|
563 | if ( !entry.attributes.has( 'listItemId' ) ) {
|
564 | findAndAddListHeadToMap( entry.position.getShiftedBy( entry.length ), itemToListHead );
|
565 | }
|
566 |
|
567 |
|
568 | for ( const { item: innerItem, previousPosition } of model.createRangeIn( item ) ) {
|
569 | if ( isListItemBlock( innerItem ) ) {
|
570 | findAndAddListHeadToMap( previousPosition, itemToListHead );
|
571 | }
|
572 | }
|
573 | }
|
574 |
|
575 | else if ( entry.type == 'remove' ) {
|
576 | findAndAddListHeadToMap( entry.position, itemToListHead );
|
577 | }
|
578 |
|
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 |
|
589 | const seenIds = new Set();
|
590 |
|
591 | for ( const listHead of itemToListHead.values() ) {
|
592 | |
593 |
|
594 |
|
595 |
|
596 |
|
597 |
|
598 |
|
599 |
|
600 |
|
601 |
|
602 |
|
603 |
|
604 |
|
605 |
|
606 |
|
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 |
|
620 |
|
621 |
|
622 |
|
623 |
|
624 |
|
625 |
|
626 |
|
627 |
|
628 |
|
629 |
|
630 |
|
631 |
|
632 |
|
633 |
|
634 |
|
635 |
|
636 |
|
637 |
|
638 | function createModelIndentPasteFixer( model ) {
|
639 | return ( evt, [ content, selectable ] ) => {
|
640 |
|
641 |
|
642 |
|
643 |
|
644 |
|
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 |
|
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 |
|
670 | if ( !refItem ) {
|
671 | return;
|
672 | }
|
673 |
|
674 |
|
675 |
|
676 |
|
677 | const indentChange = refItem.getAttribute( 'listIndent' ) - item.getAttribute( 'listIndent' );
|
678 |
|
679 |
|
680 | if ( indentChange <= 0 ) {
|
681 | return;
|
682 | }
|
683 |
|
684 | model.change( writer => {
|
685 |
|
686 | for ( const { node } of iterateSiblingListBlocks( item, 'forward' ) ) {
|
687 | writer.setAttribute( 'listIndent', node.getAttribute( 'listIndent' ) + indentChange, node );
|
688 | }
|
689 | } );
|
690 | };
|
691 | }
|
692 |
|
693 |
|
694 |
|
695 |
|
696 |
|
697 |
|
698 |
|
699 |
|
700 | function 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 | }
|