UNPKG

27.7 kBJavaScriptView Raw
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 */
8import { Plugin } from 'ckeditor5/src/core';
9import { Delete } from 'ckeditor5/src/typing';
10import { Enter } from 'ckeditor5/src/enter';
11import { CKEditorError } from 'ckeditor5/src/utils';
12import DocumentListIndentCommand from './documentlistindentcommand';
13import DocumentListCommand from './documentlistcommand';
14import DocumentListMergeCommand from './documentlistmergecommand';
15import DocumentListSplitCommand from './documentlistsplitcommand';
16import DocumentListUtils from './documentlistutils';
17import { bogusParagraphCreator, listItemDowncastConverter, listItemUpcastConverter, listUpcastCleanList, reconvertItemsOnDataChange } from './converters';
18import { findAndAddListHeadToMap, fixListIndents, fixListItemIds } from './utils/postfixers';
19import { getAllListItemBlocks, isFirstBlockOfListItem, isLastBlockOfListItem, isSingleListItem, getSelectedBlockObject, isListItemBlock, removeListAttributes } from './utils/model';
20import { getViewElementIdForListType, getViewElementNameForListType } from './utils/view';
21import ListWalker, { iterateSiblingListBlocks, ListBlocksIterable } from './utils/listwalker';
22import '../../theme/documentlist.css';
23import '../../theme/list.css';
24/**
25 * A list of base list model attributes.
26 */
27const 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 */
31export 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 */
419function 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 */
495function 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 */
547function 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}