@ckeditor/ckeditor5-list
Version:
Ordered and unordered lists feature to CKEditor 5.
675 lines (674 loc) • 33.2 kB
JavaScript
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module list/list/listediting
*/
import { Plugin } from 'ckeditor5/src/core.js';
import { Delete } from 'ckeditor5/src/typing.js';
import { Enter } from 'ckeditor5/src/enter.js';
import { CKEditorError } from 'ckeditor5/src/utils.js';
import ListIndentCommand from './listindentcommand.js';
import ListCommand from './listcommand.js';
import ListMergeCommand from './listmergecommand.js';
import ListSplitCommand from './listsplitcommand.js';
import ListUtils from './listutils.js';
import { bogusParagraphCreator, createModelToViewPositionMapper, listItemDowncastConverter, listItemDowncastRemoveConverter, listItemUpcastConverter, listUpcastCleanList, reconvertItemsOnDataChange } from './converters.js';
import { findAndAddListHeadToMap, fixListIndents, fixListItemIds } from './utils/postfixers.js';
import { getAllListItemBlocks, isFirstBlockOfListItem, isLastBlockOfListItem, isSingleListItem, getSelectedBlockObject, isListItemBlock, removeListAttributes, ListItemUid } from './utils/model.js';
import { getViewElementIdForListType, getViewElementNameForListType } from './utils/view.js';
import ListWalker, { ListBlocksIterable } from './utils/listwalker.js';
import { ClipboardPipeline } from 'ckeditor5/src/clipboard.js';
import '../../theme/documentlist.css';
import '../../theme/list.css';
/**
* A list of base list model attributes.
*/
const LIST_BASE_ATTRIBUTES = ['listType', 'listIndent', 'listItemId'];
/**
* The editing part of the document-list feature. It handles creating, editing and removing lists and list items.
*/
export default class ListEditing extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'ListEditing';
}
/**
* @inheritDoc
*/
static get requires() {
return [Enter, Delete, ListUtils, ClipboardPipeline];
}
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
/**
* The list of registered downcast strategies.
*/
this._downcastStrategies = [];
editor.config.define('list.multiBlock', true);
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const model = editor.model;
const multiBlock = editor.config.get('list.multiBlock');
if (editor.plugins.has('LegacyListEditing')) {
/**
* The `List` feature can not be loaded together with the `LegacyList` plugin.
*
* @error list-feature-conflict
* @param conflictPlugin Name of the plugin.
*/
throw new CKEditorError('list-feature-conflict', this, { conflictPlugin: 'LegacyListEditing' });
}
model.schema.register('$listItem', { allowAttributes: LIST_BASE_ATTRIBUTES });
if (multiBlock) {
model.schema.extend('$container', { allowAttributesOf: '$listItem' });
model.schema.extend('$block', { allowAttributesOf: '$listItem' });
model.schema.extend('$blockObject', { allowAttributesOf: '$listItem' });
}
else {
model.schema.register('listItem', {
inheritAllFrom: '$block',
allowAttributesOf: '$listItem'
});
}
for (const attribute of LIST_BASE_ATTRIBUTES) {
model.schema.setAttributeProperties(attribute, {
copyOnReplace: true
});
}
// Register commands.
editor.commands.add('numberedList', new ListCommand(editor, 'numbered'));
editor.commands.add('bulletedList', new ListCommand(editor, 'bulleted'));
editor.commands.add('customNumberedList', new ListCommand(editor, 'customNumbered', { multiLevel: true }));
editor.commands.add('customBulletedList', new ListCommand(editor, 'customBulleted', { multiLevel: true }));
editor.commands.add('indentList', new ListIndentCommand(editor, 'forward'));
editor.commands.add('outdentList', new ListIndentCommand(editor, 'backward'));
editor.commands.add('splitListItemBefore', new ListSplitCommand(editor, 'before'));
editor.commands.add('splitListItemAfter', new ListSplitCommand(editor, 'after'));
if (multiBlock) {
editor.commands.add('mergeListItemBackward', new ListMergeCommand(editor, 'backward'));
editor.commands.add('mergeListItemForward', new ListMergeCommand(editor, 'forward'));
}
this._setupDeleteIntegration();
this._setupEnterIntegration();
this._setupTabIntegration();
this._setupClipboardIntegration();
this._setupAccessibilityIntegration();
}
/**
* @inheritDoc
*/
afterInit() {
const editor = this.editor;
const commands = editor.commands;
const indent = commands.get('indent');
const outdent = commands.get('outdent');
if (indent) {
// Priority is high due to integration with `IndentBlock` plugin. We want to indent list first and if it's not possible
// user can indent content with `IndentBlock` plugin.
indent.registerChildCommand(commands.get('indentList'), { priority: 'high' });
}
if (outdent) {
// Priority is lowest due to integration with `IndentBlock` and `IndentCode` plugins.
// First we want to allow user to outdent all indendations from other features then he can oudent list item.
outdent.registerChildCommand(commands.get('outdentList'), { priority: 'lowest' });
}
// Register conversion and model post-fixer after other plugins had a chance to register their attribute strategies.
this._setupModelPostFixing();
this._setupConversion();
}
/**
* Registers a downcast strategy.
*
* **Note**: Strategies must be registered in the `Plugin#init()` phase so that it can be applied
* in the `ListEditing#afterInit()`.
*
* @param strategy The downcast strategy to register.
*/
registerDowncastStrategy(strategy) {
this._downcastStrategies.push(strategy);
}
/**
* Returns list of model attribute names that should affect downcast conversion.
*/
getListAttributeNames() {
return [
...LIST_BASE_ATTRIBUTES,
...this._downcastStrategies.map(strategy => strategy.attributeName)
];
}
/**
* Attaches the listener to the {@link module:engine/view/document~Document#event:delete} event and handles backspace/delete
* keys in and around document lists.
*/
_setupDeleteIntegration() {
const editor = this.editor;
const mergeBackwardCommand = editor.commands.get('mergeListItemBackward');
const mergeForwardCommand = editor.commands.get('mergeListItemForward');
this.listenTo(editor.editing.view.document, 'delete', (evt, data) => {
const selection = editor.model.document.selection;
// Let the Widget plugin take care of block widgets while deleting (https://github.com/ckeditor/ckeditor5/issues/11346).
if (getSelectedBlockObject(editor.model)) {
return;
}
editor.model.change(() => {
const firstPosition = selection.getFirstPosition();
if (selection.isCollapsed && data.direction == 'backward') {
if (!firstPosition.isAtStart) {
return;
}
const positionParent = firstPosition.parent;
if (!isListItemBlock(positionParent)) {
return;
}
const previousBlock = ListWalker.first(positionParent, {
sameAttributes: 'listType',
sameIndent: true
});
// Outdent the first block of a first list item.
if (!previousBlock && positionParent.getAttribute('listIndent') === 0) {
if (!isLastBlockOfListItem(positionParent)) {
editor.execute('splitListItemAfter');
}
editor.execute('outdentList');
}
// Merge block with previous one (on the block level or on the content level).
else {
if (!mergeBackwardCommand || !mergeBackwardCommand.isEnabled) {
return;
}
mergeBackwardCommand.execute({
shouldMergeOnBlocksContentLevel: shouldMergeOnBlocksContentLevel(editor.model, 'backward')
});
}
data.preventDefault();
evt.stop();
}
// Non-collapsed selection or forward delete.
else {
// Collapsed selection should trigger forward merging only if at the end of a block.
if (selection.isCollapsed && !selection.getLastPosition().isAtEnd) {
return;
}
if (!mergeForwardCommand || !mergeForwardCommand.isEnabled) {
return;
}
mergeForwardCommand.execute({
shouldMergeOnBlocksContentLevel: shouldMergeOnBlocksContentLevel(editor.model, 'forward')
});
data.preventDefault();
evt.stop();
}
});
}, { context: 'li' });
}
/**
* Attaches a listener to the {@link module:engine/view/document~Document#event:enter} event and handles enter key press
* in document lists.
*/
_setupEnterIntegration() {
const editor = this.editor;
const model = editor.model;
const commands = editor.commands;
const enterCommand = commands.get('enter');
// Overwrite the default Enter key behavior: outdent or split the list in certain cases.
this.listenTo(editor.editing.view.document, 'enter', (evt, data) => {
const doc = model.document;
const positionParent = doc.selection.getFirstPosition().parent;
if (doc.selection.isCollapsed &&
isListItemBlock(positionParent) &&
positionParent.isEmpty &&
!data.isSoft) {
const isFirstBlock = isFirstBlockOfListItem(positionParent);
const isLastBlock = isLastBlockOfListItem(positionParent);
// * a → * a
// * [] → []
if (isFirstBlock && isLastBlock) {
editor.execute('outdentList');
data.preventDefault();
evt.stop();
}
// * [] → * []
// a → * a
else if (isFirstBlock && !isLastBlock) {
editor.execute('splitListItemAfter');
data.preventDefault();
evt.stop();
}
// * a → * a
// [] → * []
else if (isLastBlock) {
editor.execute('splitListItemBefore');
data.preventDefault();
evt.stop();
}
}
}, { context: 'li' });
// In some cases, after the default block splitting, we want to modify the new block to become a new list item
// instead of an additional block in the same list item.
this.listenTo(enterCommand, 'afterExecute', () => {
const splitCommand = commands.get('splitListItemBefore');
// The command has not refreshed because the change block related to EnterCommand#execute() is not over yet.
// Let's keep it up to date and take advantage of ListSplitCommand#isEnabled.
splitCommand.refresh();
if (!splitCommand.isEnabled) {
return;
}
const doc = editor.model.document;
const positionParent = doc.selection.getLastPosition().parent;
const listItemBlocks = getAllListItemBlocks(positionParent);
// Keep in mind this split happens after the default enter handler was executed. For instance:
//
// │ Initial state │ After default enter │ Here in #afterExecute │
// ├───────────────────────────┼───────────────────────────┼───────────────────────────┤
// │ * a[] │ * a │ * a │
// │ │ [] │ * [] │
if (listItemBlocks.length === 2) {
splitCommand.execute();
}
});
}
/**
* Attaches a listener to the {@link module:engine/view/document~Document#event:tab} event and handles tab key and tab+shift keys
* presses in document lists.
*/
_setupTabIntegration() {
const editor = this.editor;
this.listenTo(editor.editing.view.document, 'tab', (evt, data) => {
const commandName = data.shiftKey ? 'outdentList' : 'indentList';
const command = this.editor.commands.get(commandName);
if (command.isEnabled) {
editor.execute(commandName);
data.stopPropagation();
data.preventDefault();
evt.stop();
}
}, { context: 'li' });
}
/**
* Registers the conversion helpers for the document-list feature.
*/
_setupConversion() {
const editor = this.editor;
const model = editor.model;
const attributeNames = this.getListAttributeNames();
const multiBlock = editor.config.get('list.multiBlock');
const elementName = multiBlock ? 'paragraph' : 'listItem';
editor.conversion.for('upcast')
// Convert <li> to a generic paragraph (or listItem element) so the content of <li> is always inside a block.
// Setting the listType attribute to let other features (to-do list) know that this is part of a list item.
// This is also important to properly handle simple lists so that paragraphs inside a list item won't break the list item.
// <li> <-- converted to listItem
// <p></p> <-- should be also converted to listItem, so it won't split and replace the listItem generated from the above li.
.elementToElement({
view: 'li',
model: (viewElement, { writer }) => writer.createElement(elementName, { listType: '' })
})
// Convert paragraph to the list block (without list type defined yet).
// This is important to properly handle bogus paragraph and to-do lists.
// Most of the time the bogus paragraph should not appear in the data of to-do list,
// but if there is any marker or an attribute on the paragraph then the bogus paragraph
// is preserved in the data, and we need to be able to detect this case.
.elementToElement({
view: 'p',
model: (viewElement, { writer }) => {
if (viewElement.parent && viewElement.parent.is('element', 'li')) {
return writer.createElement(elementName, { listType: '' });
}
return null;
},
converterPriority: 'high'
})
.add(dispatcher => {
dispatcher.on('element:li', listItemUpcastConverter());
dispatcher.on('element:ul', listUpcastCleanList(), { priority: 'high' });
dispatcher.on('element:ol', listUpcastCleanList(), { priority: 'high' });
});
if (!multiBlock) {
editor.conversion.for('downcast')
.elementToElement({
model: 'listItem',
view: 'p'
});
}
editor.conversion.for('editingDowncast')
.elementToElement({
model: elementName,
view: bogusParagraphCreator(attributeNames),
converterPriority: 'high'
})
.add(dispatcher => {
dispatcher.on('attribute', listItemDowncastConverter(attributeNames, this._downcastStrategies, model));
dispatcher.on('remove', listItemDowncastRemoveConverter(model.schema));
});
editor.conversion.for('dataDowncast')
.elementToElement({
model: elementName,
view: bogusParagraphCreator(attributeNames, { dataPipeline: true }),
converterPriority: 'high'
})
.add(dispatcher => {
dispatcher.on('attribute', listItemDowncastConverter(attributeNames, this._downcastStrategies, model, { dataPipeline: true }));
});
const modelToViewPositionMapper = createModelToViewPositionMapper(this._downcastStrategies, editor.editing.view);
editor.editing.mapper.on('modelToViewPosition', modelToViewPositionMapper);
editor.data.mapper.on('modelToViewPosition', modelToViewPositionMapper);
this.listenTo(model.document, 'change:data', reconvertItemsOnDataChange(model, editor.editing, attributeNames, this), { priority: 'high' });
// For LI verify if an ID of the attribute element is correct.
this.on('checkAttributes:item', (evt, { viewElement, modelAttributes }) => {
if (viewElement.id != modelAttributes.listItemId) {
evt.return = true;
evt.stop();
}
});
// For UL and OL check if the name and ID of element is correct.
this.on('checkAttributes:list', (evt, { viewElement, modelAttributes }) => {
if (viewElement.name != getViewElementNameForListType(modelAttributes.listType) ||
viewElement.id != getViewElementIdForListType(modelAttributes.listType, modelAttributes.listIndent)) {
evt.return = true;
evt.stop();
}
});
}
/**
* Registers model post-fixers.
*/
_setupModelPostFixing() {
const model = this.editor.model;
const attributeNames = this.getListAttributeNames();
// Register list fixing.
// First the low level handler.
model.document.registerPostFixer(writer => modelChangePostFixer(model, writer, attributeNames, this));
// Then the callbacks for the specific lists.
// The indentation fixing must be the first one...
this.on('postFixer', (evt, { listNodes, writer }) => {
evt.return = fixListIndents(listNodes, writer) || evt.return;
}, { priority: 'high' });
// ...then the item ids... and after that other fixers that rely on the correct indentation and ids.
this.on('postFixer', (evt, { listNodes, writer, seenIds }) => {
evt.return = fixListItemIds(listNodes, seenIds, writer) || evt.return;
}, { priority: 'high' });
}
/**
* Integrates the feature with the clipboard via {@link module:engine/model/model~Model#insertContent} and
* {@link module:engine/model/model~Model#getSelectedContent}.
*/
_setupClipboardIntegration() {
const model = this.editor.model;
const clipboardPipeline = this.editor.plugins.get('ClipboardPipeline');
this.listenTo(model, 'insertContent', createModelIndentPasteFixer(model), { priority: 'high' });
// To enhance the UX, the editor should not copy list attributes to the clipboard if the selection
// started and ended in the same list item.
//
// If the selection was enclosed in a single list item, there is a good chance the user did not want it
// copied as a list item but plain blocks.
//
// This avoids pasting orphaned list items instead of paragraphs, for instance, straight into the root.
//
// ┌─────────────────────┬───────────────────┐
// │ Selection │ Clipboard content │
// ├─────────────────────┼───────────────────┤
// │ [* <Widget />] │ <Widget /> │
// ├─────────────────────┼───────────────────┤
// │ [* Foo] │ Foo │
// ├─────────────────────┼───────────────────┤
// │ * Foo [bar] baz │ bar │
// ├─────────────────────┼───────────────────┤
// │ * Fo[o │ o │
// │ ba]r │ ba │
// ├─────────────────────┼───────────────────┤
// │ * Fo[o │ * o │
// │ * ba]r │ * ba │
// ├─────────────────────┼───────────────────┤
// │ [* Foo │ * Foo │
// │ * bar] │ * bar │
// └─────────────────────┴───────────────────┘
//
// See https://github.com/ckeditor/ckeditor5/issues/11608, https://github.com/ckeditor/ckeditor5/issues/14969
this.listenTo(clipboardPipeline, 'outputTransformation', (evt, data) => {
model.change(writer => {
// Remove last block if it's empty.
const allContentChildren = Array.from(data.content.getChildren());
const lastItem = allContentChildren[allContentChildren.length - 1];
if (allContentChildren.length > 1 && lastItem.is('element') && lastItem.isEmpty) {
const contentChildrenExceptLastItem = allContentChildren.slice(0, -1);
if (contentChildrenExceptLastItem.every(isListItemBlock)) {
writer.remove(lastItem);
}
}
// Copy/cut only content of a list item (for drag-drop move the whole list item).
if (data.method == 'copy' || data.method == 'cut') {
const allChildren = Array.from(data.content.getChildren());
const isSingleListItemSelected = isSingleListItem(allChildren);
if (isSingleListItemSelected) {
removeListAttributes(allChildren, writer);
}
}
});
});
}
/**
* Informs editor accessibility features about keystrokes brought by the plugin.
*/
_setupAccessibilityIntegration() {
const editor = this.editor;
const t = editor.t;
editor.accessibility.addKeystrokeInfoGroup({
id: 'list',
label: t('Keystrokes that can be used in a list'),
keystrokes: [
{
label: t('Increase list item indent'),
keystroke: 'Tab'
},
{
label: t('Decrease list item indent'),
keystroke: 'Shift+Tab'
}
]
});
}
}
/**
* Post-fixer that reacts to changes on document and fixes incorrect model states (invalid `listItemId` and `listIndent` values).
*
* In the example below, there is a correct list structure.
* Then the middle element is removed so the list structure will become incorrect:
*
* ```xml
* <paragraph listType="bulleted" listItemId="a" listIndent=0>Item 1</paragraph>
* <paragraph listType="bulleted" listItemId="b" listIndent=1>Item 2</paragraph> <--- this is removed.
* <paragraph listType="bulleted" listItemId="c" listIndent=2>Item 3</paragraph>
* ```
*
* The list structure after the middle element is removed:
*
* ```xml
* <paragraph listType="bulleted" listItemId="a" listIndent=0>Item 1</paragraph>
* <paragraph listType="bulleted" listItemId="c" listIndent=2>Item 3</paragraph>
* ```
*
* Should become:
*
* ```xml
* <paragraph listType="bulleted" listItemId="a" listIndent=0>Item 1</paragraph>
* <paragraph listType="bulleted" listItemId="c" listIndent=1>Item 3</paragraph> <--- note that indent got post-fixed.
* ```
*
* @param model The data model.
* @param writer The writer to do changes with.
* @param attributeNames The list of all model list attributes (including registered strategies).
* @param ListEditing The document list editing plugin.
* @returns `true` if any change has been applied, `false` otherwise.
*/
function modelChangePostFixer(model, writer, attributeNames, listEditing) {
const changes = model.document.differ.getChanges();
const itemToListHead = new Map();
const multiBlock = listEditing.editor.config.get('list.multiBlock');
let applied = false;
for (const entry of changes) {
if (entry.type == 'insert' && entry.name != '$text') {
const item = entry.position.nodeAfter;
// Remove attributes in case of renamed element.
if (!model.schema.checkAttribute(item, 'listItemId')) {
for (const attributeName of Array.from(item.getAttributeKeys())) {
if (attributeNames.includes(attributeName)) {
writer.removeAttribute(attributeName, item);
applied = true;
}
}
}
findAndAddListHeadToMap(entry.position, itemToListHead);
// Insert of a non-list item - check if there is a list after it.
if (!entry.attributes.has('listItemId')) {
findAndAddListHeadToMap(entry.position.getShiftedBy(entry.length), itemToListHead);
}
// Check if there is no nested list.
for (const { item: innerItem, previousPosition } of model.createRangeIn(item)) {
if (isListItemBlock(innerItem)) {
findAndAddListHeadToMap(previousPosition, itemToListHead);
}
}
}
// Removed list item or block adjacent to a list.
else if (entry.type == 'remove') {
findAndAddListHeadToMap(entry.position, itemToListHead);
}
// Changed list item indent or type.
else if (entry.type == 'attribute' && attributeNames.includes(entry.attributeKey)) {
findAndAddListHeadToMap(entry.range.start, itemToListHead);
if (entry.attributeNewValue === null) {
findAndAddListHeadToMap(entry.range.start.getShiftedBy(1), itemToListHead);
}
}
// Make sure that there is no left over listItem element without attributes or a block with list attributes that is not a listItem.
if (!multiBlock && entry.type == 'attribute' && LIST_BASE_ATTRIBUTES.includes(entry.attributeKey)) {
const element = entry.range.start.nodeAfter;
if (entry.attributeNewValue === null && element && element.is('element', 'listItem')) {
writer.rename(element, 'paragraph');
applied = true;
}
else if (entry.attributeOldValue === null && element && element.is('element') && element.name != 'listItem') {
writer.rename(element, 'listItem');
applied = true;
}
}
}
// Make sure that IDs are not shared by split list.
const seenIds = new Set();
for (const listHead of itemToListHead.values()) {
applied = listEditing.fire('postFixer', {
listNodes: new ListBlocksIterable(listHead),
listHead,
writer,
seenIds
}) || applied;
}
return applied;
}
/**
* A fixer for pasted content that includes list items.
*
* It fixes indentation of pasted list items so the pasted items match correctly to the context they are pasted into.
*
* Example:
*
* ```xml
* <paragraph listType="bulleted" listItemId="a" listIndent="0">A</paragraph>
* <paragraph listType="bulleted" listItemId="b" listIndent="1">B^</paragraph>
* // At ^ paste: <paragraph listType="numbered" listItemId="x" listIndent="0">X</paragraph>
* // <paragraph listType="numbered" listItemId="y" listIndent="1">Y</paragraph>
* <paragraph listType="bulleted" listItemId="c" listIndent="2">C</paragraph>
* ```
*
* Should become:
*
* ```xml
* <paragraph listType="bulleted" listItemId="a" listIndent="0">A</paragraph>
* <paragraph listType="bulleted" listItemId="b" listIndent="1">BX</paragraph>
* <paragraph listType="bulleted" listItemId="y" listIndent="2">Y/paragraph>
* <paragraph listType="bulleted" listItemId="c" listIndent="2">C</paragraph>
* ```
*/
function createModelIndentPasteFixer(model) {
return (evt, [content, selectable]) => {
const items = content.is('documentFragment') ?
Array.from(content.getChildren()) :
[content];
if (!items.length) {
return;
}
const selection = selectable ?
model.createSelection(selectable) :
model.document.selection;
const position = selection.getFirstPosition();
// Get a reference list item. Attributes of the inserted list items will be fixed according to that item.
let refItem;
if (isListItemBlock(position.parent)) {
refItem = position.parent;
}
else if (isListItemBlock(position.nodeBefore)) {
refItem = position.nodeBefore;
}
else {
return; // Content is not copied into a list.
}
model.change(writer => {
const refType = refItem.getAttribute('listType');
const refIndent = refItem.getAttribute('listIndent');
const firstElementIndent = items[0].getAttribute('listIndent') || 0;
const indentDiff = Math.max(refIndent - firstElementIndent, 0);
for (const item of items) {
const isListItem = isListItemBlock(item);
if (refItem.is('element', 'listItem') && item.is('element', 'paragraph')) {
/**
* When paragraphs or a plain text list is pasted into a simple list, convert
* the `<paragraphs>' to `<listItem>' to avoid breaking the target list.
*
* See https://github.com/ckeditor/ckeditor5/issues/13826.
*/
writer.rename(item, 'listItem');
}
writer.setAttributes({
listIndent: (isListItem ? item.getAttribute('listIndent') : 0) + indentDiff,
listItemId: isListItem ? item.getAttribute('listItemId') : ListItemUid.next(),
listType: refType
}, item);
}
});
};
}
/**
* Decides whether the merge should be accompanied by the model's `deleteContent()`, for instance, to get rid of the inline
* content in the selection or take advantage of the heuristics in `deleteContent()` that helps convert lists into paragraphs
* in certain cases.
*/
function shouldMergeOnBlocksContentLevel(model, direction) {
const selection = model.document.selection;
if (!selection.isCollapsed) {
return !getSelectedBlockObject(model);
}
if (direction === 'forward') {
return true;
}
const firstPosition = selection.getFirstPosition();
const positionParent = firstPosition.parent;
const previousSibling = positionParent.previousSibling;
if (model.schema.isObject(previousSibling)) {
return false;
}
if (previousSibling.isEmpty) {
return true;
}
return isSingleListItem([positionParent, previousSibling]);
}