@ckeditor/ckeditor5-list
Version:
Ordered and unordered lists feature to CKEditor 5.
697 lines (696 loc) • 31 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/legacylistproperties/legacylistpropertiesediting
*/
import { Plugin } from 'ckeditor5/src/core.js';
import LegacyListEditing from '../legacylist/legacylistediting.js';
import LegacyListStyleCommand from './legacyliststylecommand.js';
import LegacyListReversedCommand from './legacylistreversedcommand.js';
import LegacyListStartCommand from './legacyliststartcommand.js';
import { getSiblingListItem, getSiblingNodes } from '../legacylist/legacyutils.js';
const DEFAULT_LIST_TYPE = 'default';
/**
* The engine of the list properties feature.
*
* It sets the value for the `listItem` attribute of the {@link module:list/legacylist~LegacyList `<listItem>`} element that
* allows modifying the list style type.
*
* It registers the `'listStyle'`, `'listReversed'` and `'listStart'` commands if they are enabled in the configuration.
* Read more in {@link module:list/listconfig~ListPropertiesConfig}.
*/
export default class LegacyListPropertiesEditing extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [LegacyListEditing];
}
/**
* @inheritDoc
*/
static get pluginName() {
return 'LegacyListPropertiesEditing';
}
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
editor.config.define('list', {
properties: {
styles: true,
startIndex: false,
reversed: false
}
});
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const model = editor.model;
const enabledProperties = editor.config.get('list.properties');
const strategies = createAttributeStrategies(enabledProperties);
// Extend schema.
model.schema.extend('listItem', {
allowAttributes: strategies.map(s => s.attributeName)
});
for (const strategy of strategies) {
strategy.addCommand(editor);
}
// Fix list attributes when modifying their nesting levels (the `listIndent` attribute).
this.listenTo(editor.commands.get('indentList'), '_executeCleanup', fixListAfterIndentListCommand(editor, strategies));
this.listenTo(editor.commands.get('outdentList'), '_executeCleanup', fixListAfterOutdentListCommand(editor, strategies));
this.listenTo(editor.commands.get('bulletedList'), '_executeCleanup', restoreDefaultListStyle(editor));
this.listenTo(editor.commands.get('numberedList'), '_executeCleanup', restoreDefaultListStyle(editor));
// Register a post-fixer that ensures that the attributes is specified in each `listItem` element.
model.document.registerPostFixer(fixListAttributesOnListItemElements(editor, strategies));
// Set up conversion.
editor.conversion.for('upcast').add(upcastListItemAttributes(strategies));
editor.conversion.for('downcast').add(downcastListItemAttributes(strategies));
// Handle merging two separated lists into the single one.
this._mergeListAttributesWhileMergingLists(strategies);
}
/**
* @inheritDoc
*/
afterInit() {
const editor = this.editor;
// Enable post-fixer that removes the attributes from to-do list items only if the "TodoList" plugin is on.
// We need to registry the hook here since the `TodoList` plugin can be added after the `ListPropertiesEditing`.
if (editor.commands.get('todoList')) {
editor.model.document.registerPostFixer(removeListItemAttributesFromTodoList(editor));
}
}
/**
* Starts listening to {@link module:engine/model/model~Model#deleteContent} and checks whether two lists will be merged into a single
* one after deleting the content.
*
* The purpose of this action is to adjust the `listStyle`, `listReversed` and `listStart` values
* for the list that was merged.
*
* Consider the following model's content:
*
* ```xml
* <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
* <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 2</listItem>
* <paragraph>[A paragraph.]</paragraph>
* <listItem listIndent="0" listType="bulleted" listStyle="circle">UL List item 1</listItem>
* <listItem listIndent="0" listType="bulleted" listStyle="circle">UL List item 2</listItem>
* ```
*
* After removing the paragraph element, the second list will be merged into the first one.
* We want to inherit the `listStyle` attribute for the second list from the first one.
*
* ```xml
* <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
* <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 2</listItem>
* <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
* <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 2</listItem>
* ```
*
* See https://github.com/ckeditor/ckeditor5/issues/7879.
*
* @param attributeStrategies Strategies for the enabled attributes.
*/
_mergeListAttributesWhileMergingLists(attributeStrategies) {
const editor = this.editor;
const model = editor.model;
// First the outer-most`listItem` in the first list reference.
// If found, the lists should be merged and this `listItem` provides the attributes
// and it is also a starting point when searching for items in the second list.
let firstMostOuterItem;
// Check whether the removed content is between two lists.
this.listenTo(model, 'deleteContent', (evt, [selection]) => {
const firstPosition = selection.getFirstPosition();
const lastPosition = selection.getLastPosition();
// Typing or removing content in a single item. Aborting.
if (firstPosition.parent === lastPosition.parent) {
return;
}
// An element before the content that will be removed is not a list.
if (!firstPosition.parent.is('element', 'listItem')) {
return;
}
const nextSibling = lastPosition.parent.nextSibling;
// An element after the content that will be removed is not a list.
if (!nextSibling || !nextSibling.is('element', 'listItem')) {
return;
}
// Find the outermost list item based on the `listIndent` attribute. We can't assume that `listIndent=0`
// because the selection can be hooked in nested lists.
//
// <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
// <listItem listIndent="1" listType="bulleted" listStyle="square">UL List [item 1.1</listItem>
// <listItem listIndent="0" listType="bulleted" listStyle="circle">[]UL List item 1.</listItem>
// <listItem listIndent="1" listType="bulleted" listStyle="circle">UL List ]item 1.1</listItem>
//
// After deleting the content, we would like to inherit the "square" attribute for the last element:
//
// <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
// <listItem listIndent="1" listType="bulleted" listStyle="square">UL List []item 1.1</listItem>
const mostOuterItemList = getSiblingListItem(firstPosition.parent, {
sameIndent: true,
listIndent: nextSibling.getAttribute('listIndent')
});
// The outermost list item may not exist while removing elements between lists with different value
// of the `listIndent` attribute. In such a case we don't want to update anything. See: #8073.
if (!mostOuterItemList) {
return;
}
if (mostOuterItemList.getAttribute('listType') === nextSibling.getAttribute('listType')) {
firstMostOuterItem = mostOuterItemList;
}
}, { priority: 'high' });
// If so, update the `listStyle` attribute for the second list.
this.listenTo(model, 'deleteContent', () => {
if (!firstMostOuterItem) {
return;
}
model.change(writer => {
// Find the first most-outer item list in the merged list.
// A case when the first list item in the second list was merged into the last item in the first list.
//
// <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
// <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 2</listItem>
// <listItem listIndent="0" listType="bulleted" listStyle="circle">[]UL List item 1</listItem>
// <listItem listIndent="0" listType="bulleted" listStyle="circle">UL List item 2</listItem>
const secondListMostOuterItem = getSiblingListItem(firstMostOuterItem.nextSibling, {
sameIndent: true,
listIndent: firstMostOuterItem.getAttribute('listIndent'),
direction: 'forward'
});
// If the selection ends in a non-list element, there are no <listItem>s that would require adjustments.
// See: #8642.
if (!secondListMostOuterItem) {
firstMostOuterItem = null;
return;
}
const items = [
secondListMostOuterItem,
...getSiblingNodes(writer.createPositionAt(secondListMostOuterItem, 0), 'forward')
];
for (const listItem of items) {
for (const strategy of attributeStrategies) {
if (strategy.appliesToListItem(listItem)) {
const attributeName = strategy.attributeName;
const value = firstMostOuterItem.getAttribute(attributeName);
writer.setAttribute(attributeName, value, listItem);
}
}
}
});
firstMostOuterItem = null;
}, { priority: 'low' });
}
}
/**
* Creates an array of strategies for dealing with enabled listItem attributes.
*/
function createAttributeStrategies(enabledProperties) {
const strategies = [];
if (enabledProperties.styles) {
strategies.push({
attributeName: 'listStyle',
defaultValue: DEFAULT_LIST_TYPE,
addCommand(editor) {
editor.commands.add('listStyle', new LegacyListStyleCommand(editor, DEFAULT_LIST_TYPE));
},
appliesToListItem() {
return true;
},
setAttributeOnDowncast(writer, listStyle, element) {
if (listStyle && listStyle !== DEFAULT_LIST_TYPE) {
writer.setStyle('list-style-type', listStyle, element);
}
else {
writer.removeStyle('list-style-type', element);
}
},
getAttributeOnUpcast(listParent) {
return listParent.getStyle('list-style-type') || DEFAULT_LIST_TYPE;
}
});
}
if (enabledProperties.reversed) {
strategies.push({
attributeName: 'listReversed',
defaultValue: false,
addCommand(editor) {
editor.commands.add('listReversed', new LegacyListReversedCommand(editor));
},
appliesToListItem(item) {
return item.getAttribute('listType') == 'numbered';
},
setAttributeOnDowncast(writer, listReversed, element) {
if (listReversed) {
writer.setAttribute('reversed', 'reversed', element);
}
else {
writer.removeAttribute('reversed', element);
}
},
getAttributeOnUpcast(listParent) {
return listParent.hasAttribute('reversed');
}
});
}
if (enabledProperties.startIndex) {
strategies.push({
attributeName: 'listStart',
defaultValue: 1,
addCommand(editor) {
editor.commands.add('listStart', new LegacyListStartCommand(editor));
},
appliesToListItem(item) {
return item.getAttribute('listType') == 'numbered';
},
setAttributeOnDowncast(writer, listStart, element) {
if (listStart == 0 || listStart > 1) {
writer.setAttribute('start', listStart, element);
}
else {
writer.removeAttribute('start', element);
}
},
getAttributeOnUpcast(listParent) {
const startAttributeValue = listParent.getAttribute('start');
return startAttributeValue >= 0 ? startAttributeValue : 1;
}
});
}
return strategies;
}
/**
* Returns a converter consumes the `style`, `reversed` and `start` attribute.
* In `style` it searches for the `list-style-type` definition.
* If not found, the `"default"` value will be used.
*/
function upcastListItemAttributes(attributeStrategies) {
return (dispatcher) => {
dispatcher.on('element:li', (evt, data, conversionApi) => {
// https://github.com/ckeditor/ckeditor5/issues/13858
if (!data.modelRange) {
return;
}
const listParent = data.viewItem.parent;
const listItem = data.modelRange.start.nodeAfter || data.modelRange.end.nodeBefore;
for (const strategy of attributeStrategies) {
if (strategy.appliesToListItem(listItem)) {
const listStyle = strategy.getAttributeOnUpcast(listParent);
conversionApi.writer.setAttribute(strategy.attributeName, listStyle, listItem);
}
}
}, { priority: 'low' });
};
}
/**
* Returns a converter that adds `reversed`, `start` attributes and adds `list-style-type` definition as a value for the `style` attribute.
* The `"default"` values are removed and not present in the view/data.
*/
function downcastListItemAttributes(attributeStrategies) {
return (dispatcher) => {
for (const strategy of attributeStrategies) {
dispatcher.on(`attribute:${strategy.attributeName}:listItem`, (evt, data, conversionApi) => {
const viewWriter = conversionApi.writer;
const currentElement = data.item;
const previousElement = getSiblingListItem(currentElement.previousSibling, {
sameIndent: true,
listIndent: currentElement.getAttribute('listIndent'),
direction: 'backward'
});
const viewItem = conversionApi.mapper.toViewElement(currentElement);
// A case when elements represent different lists. We need to separate their container.
if (!areRepresentingSameList(currentElement, previousElement)) {
viewWriter.breakContainer(viewWriter.createPositionBefore(viewItem));
}
strategy.setAttributeOnDowncast(viewWriter, data.attributeNewValue, viewItem.parent);
}, { priority: 'low' });
}
};
/**
* Checks whether specified list items belong to the same list.
*/
function areRepresentingSameList(listItem1, listItem2) {
return listItem2 &&
listItem1.getAttribute('listType') === listItem2.getAttribute('listType') &&
listItem1.getAttribute('listIndent') === listItem2.getAttribute('listIndent') &&
listItem1.getAttribute('listStyle') === listItem2.getAttribute('listStyle') &&
listItem1.getAttribute('listReversed') === listItem2.getAttribute('listReversed') &&
listItem1.getAttribute('listStart') === listItem2.getAttribute('listStart');
}
}
/**
* When indenting list, nested list should clear its value for the attributes or inherit from nested lists.
*
* ■ List item 1.
* ■ List item 2.[]
* ■ List item 3.
* editor.execute( 'indentList' );
*
* ■ List item 1.
* ○ List item 2.[]
* ■ List item 3.
*/
function fixListAfterIndentListCommand(editor, attributeStrategies) {
return (evt, changedItems) => {
const root = changedItems[0];
const rootIndent = root.getAttribute('listIndent');
const itemsToUpdate = changedItems.filter(item => item.getAttribute('listIndent') === rootIndent);
// A case where a few list items are indented must be checked separately
// since `getSiblingListItem()` returns the first changed element.
// ■ List item 1.
// ○ [List item 2.
// ○ List item 3.]
// ■ List item 4.
//
// List items: `2` and `3` should be adjusted.
let previousSibling = null;
if (root.previousSibling.getAttribute('listIndent') + 1 !== rootIndent) {
previousSibling = getSiblingListItem(root.previousSibling, {
sameIndent: true, direction: 'backward', listIndent: rootIndent
});
}
editor.model.change(writer => {
for (const item of itemsToUpdate) {
for (const strategy of attributeStrategies) {
if (strategy.appliesToListItem(item)) {
const valueToSet = previousSibling == null ?
strategy.defaultValue :
previousSibling.getAttribute(strategy.attributeName);
writer.setAttribute(strategy.attributeName, valueToSet, item);
}
}
}
});
};
}
/**
* When outdenting a list, a nested list should copy attribute values
* from the previous sibling list item including the same value for the `listIndent` value.
*
* ■ List item 1.
* ○ List item 2.[]
* ■ List item 3.
*
* editor.execute( 'outdentList' );
*
* ■ List item 1.
* ■ List item 2.[]
* ■ List item 3.
*/
function fixListAfterOutdentListCommand(editor, attributeStrategies) {
return (evt, changedItems) => {
changedItems = changedItems.reverse().filter(item => item.is('element', 'listItem'));
if (!changedItems.length) {
return;
}
const indent = changedItems[0].getAttribute('listIndent');
const listType = changedItems[0].getAttribute('listType');
let listItem = changedItems[0].previousSibling;
// ■ List item 1.
// ○ List item 2.
// ○ List item 3.[]
// ■ List item 4.
//
// After outdenting a list, `List item 3` should inherit the `listStyle` attribute from `List item 1`.
//
// ■ List item 1.
// ○ List item 2.
// ■ List item 3.[]
// ■ List item 4.
if (listItem.is('element', 'listItem')) {
while (listItem.getAttribute('listIndent') !== indent) {
listItem = listItem.previousSibling;
}
}
else {
listItem = null;
}
// Outdenting such a list should restore values based on `List item 4`.
// ■ List item 1.[]
// ○ List item 2.
// ○ List item 3.
// ■ List item 4.
if (!listItem) {
listItem = changedItems[changedItems.length - 1].nextSibling;
}
// And such a list should not modify anything.
// However, `listItem` can indicate a node below the list. Be sure that we have the `listItem` element.
// ■ List item 1.[]
// ○ List item 2.
// ○ List item 3.
// <paragraph>The later if check.</paragraph>
if (!listItem || !listItem.is('element', 'listItem')) {
return;
}
// Do not modify the list if found `listItem` represents other type of list than outdented list items.
if (listItem.getAttribute('listType') !== listType) {
return;
}
editor.model.change(writer => {
const itemsToUpdate = changedItems.filter(item => item.getAttribute('listIndent') === indent);
for (const item of itemsToUpdate) {
for (const strategy of attributeStrategies) {
if (strategy.appliesToListItem(item)) {
const attributeName = strategy.attributeName;
const valueToSet = listItem.getAttribute(attributeName);
writer.setAttribute(attributeName, valueToSet, item);
}
}
}
});
};
}
/**
* Each `listItem` element must have specified the `listStyle`, `listReversed` and `listStart` attributes
* if they are enabled and supported by its `listType`.
* This post-fixer checks whether inserted elements `listItem` elements should inherit the attribute values from
* their sibling nodes or should use the default values.
*
* Paragraph[]
* ■ List item 1. // [listStyle="square", listType="bulleted"]
* ■ List item 2. // ...
* ■ List item 3. // ...
*
* editor.execute( 'bulletedList' )
*
* ■ Paragraph[] // [listStyle="square", listType="bulleted"]
* ■ List item 1. // [listStyle="square", listType="bulleted"]
* ■ List item 2.
* ■ List item 3.
*
* It also covers a such change:
*
* [Paragraph 1
* Paragraph 2]
* ■ List item 1. // [listStyle="square", listType="bulleted"]
* ■ List item 2. // ...
* ■ List item 3. // ...
*
* editor.execute( 'numberedList' )
*
* 1. [Paragraph 1 // [listStyle="default", listType="numbered"]
* 2. Paragraph 2] // [listStyle="default", listType="numbered"]
* ■ List item 1. // [listStyle="square", listType="bulleted"]
* ■ List item 2. // ...
* ■ List item 3. // ...
*/
function fixListAttributesOnListItemElements(editor, attributeStrategies) {
return (writer) => {
let wasFixed = false;
const insertedListItems = getChangedListItems(editor.model.document.differ.getChanges())
.filter(item => {
// Don't touch todo lists. They are handled in another post-fixer.
return item.getAttribute('listType') !== 'todo';
});
if (!insertedListItems.length) {
return wasFixed;
}
// Check whether the last inserted element is next to the `listItem` element.
//
// ■ Paragraph[] // <-- The inserted item.
// ■ List item 1.
let existingListItem = insertedListItems[insertedListItems.length - 1].nextSibling;
// If it doesn't, maybe the `listItem` was inserted at the end of the list.
//
// ■ List item 1.
// ■ Paragraph[] // <-- The inserted item.
if (!existingListItem || !existingListItem.is('element', 'listItem')) {
existingListItem = insertedListItems[0].previousSibling;
if (existingListItem) {
const indent = insertedListItems[0].getAttribute('listIndent');
// But we need to find a `listItem` with the `listIndent=0` attribute.
// If doesn't, maybe the `listItem` was inserted at the end of the list.
//
// ■ List item 1.
// ○ List item 2.
// ■ Paragraph[] // <-- The inserted item.
while (existingListItem.is('element', 'listItem') && existingListItem.getAttribute('listIndent') !== indent) {
existingListItem = existingListItem.previousSibling;
// If the item does not exist, most probably there is no other content in the editor. See: #8072.
if (!existingListItem) {
break;
}
}
}
}
for (const strategy of attributeStrategies) {
const attributeName = strategy.attributeName;
for (const item of insertedListItems) {
if (!strategy.appliesToListItem(item)) {
writer.removeAttribute(attributeName, item);
continue;
}
if (!item.hasAttribute(attributeName)) {
if (shouldInheritListType(existingListItem, item, strategy)) {
writer.setAttribute(attributeName, existingListItem.getAttribute(attributeName), item);
}
else {
writer.setAttribute(attributeName, strategy.defaultValue, item);
}
wasFixed = true;
}
else {
// Adjust the `listStyle`, `listReversed` and `listStart`
// attributes for inserted (pasted) items. See #8160.
//
// ■ List item 1. // [listStyle="square", listType="bulleted"]
// ○ List item 1.1. // [listStyle="circle", listType="bulleted"]
// ○ [] (selection is here)
//
// Then, pasting a list with different attributes (listStyle, listType):
//
// 1. First. // [listStyle="decimal", listType="numbered"]
// 2. Second // [listStyle="decimal", listType="numbered"]
//
// The `listType` attribute will be corrected by the `ListEditing` converters.
// We need to adjust the `listStyle` attribute. Expected structure:
//
// ■ List item 1. // [listStyle="square", listType="bulleted"]
// ○ List item 1.1. // [listStyle="circle", listType="bulleted"]
// ○ First. // [listStyle="circle", listType="bulleted"]
// ○ Second // [listStyle="circle", listType="bulleted"]
const previousSibling = item.previousSibling;
if (shouldInheritListTypeFromPreviousItem(previousSibling, item, strategy.attributeName)) {
writer.setAttribute(attributeName, previousSibling.getAttribute(attributeName), item);
wasFixed = true;
}
}
}
}
return wasFixed;
};
}
/**
* Checks whether the `listStyle`, `listReversed` and `listStart` attributes
* should be copied from the `baseItem` element.
*
* The attribute should be copied if the inserted element does not have defined it and
* the value for the element is other than default in the base element.
*/
function shouldInheritListType(baseItem, itemToChange, attributeStrategy) {
if (!baseItem) {
return false;
}
const baseListAttribute = baseItem.getAttribute(attributeStrategy.attributeName);
if (!baseListAttribute) {
return false;
}
if (baseListAttribute == attributeStrategy.defaultValue) {
return false;
}
if (baseItem.getAttribute('listType') !== itemToChange.getAttribute('listType')) {
return false;
}
return true;
}
/**
* Checks whether the `listStyle`, `listReversed` and `listStart` attributes
* should be copied from previous list item.
*
* The attribute should be copied if there's a mismatch of styles of the pasted list into a nested list.
* Top-level lists are not normalized as we allow side-by-side list of different types.
*/
function shouldInheritListTypeFromPreviousItem(previousItem, itemToChange, attributeName) {
if (!previousItem || !previousItem.is('element', 'listItem')) {
return false;
}
if (itemToChange.getAttribute('listType') !== previousItem.getAttribute('listType')) {
return false;
}
const previousItemIndent = previousItem.getAttribute('listIndent');
if (previousItemIndent < 1 || previousItemIndent !== itemToChange.getAttribute('listIndent')) {
return false;
}
const previousItemListAttribute = previousItem.getAttribute(attributeName);
if (!previousItemListAttribute || previousItemListAttribute === itemToChange.getAttribute(attributeName)) {
return false;
}
return true;
}
/**
* Removes the `listStyle`, `listReversed` and `listStart` attributes from "todo" list items.
*/
function removeListItemAttributesFromTodoList(editor) {
return (writer) => {
const todoListItems = getChangedListItems(editor.model.document.differ.getChanges())
.filter(item => {
// Handle the todo lists only. The rest is handled in another post-fixer.
return item.getAttribute('listType') === 'todo' && (item.hasAttribute('listStyle') ||
item.hasAttribute('listReversed') ||
item.hasAttribute('listStart'));
});
if (!todoListItems.length) {
return false;
}
for (const item of todoListItems) {
writer.removeAttribute('listStyle', item);
writer.removeAttribute('listReversed', item);
writer.removeAttribute('listStart', item);
}
return true;
};
}
/**
* Restores the `listStyle` attribute after changing the list type.
*/
function restoreDefaultListStyle(editor) {
return (evt, changedItems) => {
changedItems = changedItems.filter(item => item.is('element', 'listItem'));
editor.model.change(writer => {
for (const item of changedItems) {
// Remove the attribute. Post-fixer will restore the proper value.
writer.removeAttribute('listStyle', item);
}
});
};
}
/**
* Returns the `listItem` that was inserted or changed.
*
* @param changes The changes list returned by the differ.
*/
function getChangedListItems(changes) {
const items = [];
for (const change of changes) {
const item = getItemFromChange(change);
if (item && item.is('element', 'listItem')) {
items.push(item);
}
}
return items;
}
function getItemFromChange(change) {
if (change.type === 'attribute') {
return change.range.start.nodeAfter;
}
if (change.type === 'insert') {
return change.position.nodeAfter;
}
return null;
}