@ckeditor/ckeditor5-list
Version:
Ordered and unordered lists feature to CKEditor 5.
1,446 lines (1,435 loc) • 279 kB
JavaScript
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import { Command, Plugin } from "@ckeditor/ckeditor5-core";
import { Delete } from "@ckeditor/ckeditor5-typing";
import { Enter } from "@ckeditor/ckeditor5-enter";
import { CKEditorError, FocusTracker, KeystrokeHandler, createElement, env, first, getCode, getLocalizedArrowKeyCodeDirection, global, parseKeystroke, toArray, uid } from "@ckeditor/ckeditor5-utils";
import { _normalizeFontSizeOptions } from "@ckeditor/ckeditor5-font";
import { ClipboardPipeline } from "@ckeditor/ckeditor5-clipboard";
import { ButtonView, CollapsibleView, FocusCycler, LabeledFieldView, MenuBarMenuListItemButtonView, MenuBarMenuView, SplitButtonView, SwitchButtonView, View, ViewCollection, addKeyboardHandlingForGrid, createDropdown, createLabeledInputNumber, focusChildOnDropdownOpen } from "@ckeditor/ckeditor5-ui";
import { IconBulletedList, IconListStyleArabicIndic, IconListStyleCircle, IconListStyleDecimal, IconListStyleDecimalLeadingZero, IconListStyleDisc, IconListStyleLowerLatin, IconListStyleLowerRoman, IconListStyleSquare, IconListStyleUpperLatin, IconListStyleUpperRoman, IconNumberedList, IconTodoList } from "@ckeditor/ckeditor5-icons";
import { pick } from "es-toolkit/compat";
import { DomEventObserver, Matcher, ModelTreeWalker, getViewFillerOffset } from "@ckeditor/ckeditor5-engine";
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module list/list/utils/listwalker
*/
/**
* Document list blocks iterator.
*
* @internal
*/
var ListWalker = class {
/**
* The start list item block element.
*/
_startElement;
/**
* The reference indent. Initialized by the indent of the start block.
*/
_referenceIndent;
/**
* The iterating direction.
*/
_isForward;
/**
* Whether start block should be included in the result (if it's matching other criteria).
*/
_includeSelf;
/**
* Additional attributes that must be the same for each block.
*/
_sameAttributes;
/**
* Whether blocks with the same indent level as the start block should be included in the result.
*/
_sameIndent;
/**
* Whether blocks with a lower indent level than the start block should be included in the result.
*/
_lowerIndent;
/**
* Whether blocks with a higher indent level than the start block should be included in the result.
*/
_higherIndent;
/**
* Creates a document list iterator.
*
* @param startElement The start list item block element.
* @param options.direction The iterating direction.
* @param options.includeSelf Whether start block should be included in the result (if it's matching other criteria).
* @param options.sameAttributes Additional attributes that must be the same for each block.
* @param options.sameIndent Whether blocks with the same indent level as the start block should be included
* in the result.
* @param options.lowerIndent Whether blocks with a lower indent level than the start block should be included
* in the result.
* @param options.higherIndent Whether blocks with a higher indent level than the start block should be included
* in the result.
*/
constructor(startElement, options) {
this._startElement = startElement;
this._referenceIndent = startElement.getAttribute("listIndent");
this._isForward = options.direction == "forward";
this._includeSelf = !!options.includeSelf;
this._sameAttributes = toArray(options.sameAttributes || []);
this._sameIndent = !!options.sameIndent;
this._lowerIndent = !!options.lowerIndent;
this._higherIndent = !!options.higherIndent;
}
/**
* Performs only first step of iteration and returns the result.
*
* @param startElement The start list item block element.
* @param options.direction The iterating direction.
* @param options.includeSelf Whether start block should be included in the result (if it's matching other criteria).
* @param options.sameAttributes Additional attributes that must be the same for each block.
* @param options.sameIndent Whether blocks with the same indent level as the start block should be included
* in the result.
* @param options.lowerIndent Whether blocks with a lower indent level than the start block should be included
* in the result.
* @param options.higherIndent Whether blocks with a higher indent level than the start block should be included
* in the result.
*/
static first(startElement, options) {
return first(new this(startElement, options)[Symbol.iterator]());
}
/**
* Iterable interface.
*/
*[Symbol.iterator]() {
const nestedItems = [];
for (const { node } of new SiblingListBlocksIterator(this._getStartNode(), this._isForward ? "forward" : "backward")) {
const indent = node.getAttribute("listIndent");
if (indent < this._referenceIndent) {
if (!this._lowerIndent) break;
this._referenceIndent = indent;
} else if (indent > this._referenceIndent) {
if (!this._higherIndent) continue;
if (!this._isForward) {
nestedItems.push(node);
continue;
}
} else {
if (!this._sameIndent) {
if (this._higherIndent) {
if (nestedItems.length) {
yield* nestedItems;
nestedItems.length = 0;
}
break;
}
continue;
}
if (this._sameAttributes.some((attr) => node.getAttribute(attr) !== this._startElement.getAttribute(attr))) break;
}
if (nestedItems.length) {
yield* nestedItems;
nestedItems.length = 0;
}
yield node;
}
}
/**
* Returns the model element to start iterating.
*/
_getStartNode() {
if (this._includeSelf) return this._startElement;
return this._isForward ? this._startElement.nextSibling : this._startElement.previousSibling;
}
};
/**
* Iterates sibling list blocks starting from the given node.
*
* @internal
*/
var SiblingListBlocksIterator = class {
_node;
_isForward;
_previousNodesByIndent = [];
_previous = null;
_previousNodeIndent = null;
/**
* @param node The model node.
* @param direction Iteration direction.
*/
constructor(node, direction = "forward") {
this._node = node;
this._isForward = direction === "forward";
}
[Symbol.iterator]() {
return this;
}
next() {
if (!isListItemBlock(this._node)) return {
done: true,
value: void 0
};
const nodeIndent = this._node.getAttribute("listIndent");
let previousNodeInList = null;
if (this._previous) {
const previousNodeIndent = this._previousNodeIndent;
if (nodeIndent > previousNodeIndent) this._previousNodesByIndent[previousNodeIndent] = this._previous;
else if (nodeIndent < previousNodeIndent) {
previousNodeInList = this._previousNodesByIndent[nodeIndent] || null;
this._previousNodesByIndent.length = nodeIndent;
} else previousNodeInList = this._previous;
}
const value = {
node: this._node,
previous: this._previous,
previousNodeInList
};
this._previous = this._node;
this._previousNodeIndent = nodeIndent;
this._node = this._isForward ? this._node.nextSibling : this._node.previousSibling;
return {
value,
done: false
};
}
};
/**
* The iterable protocol over the list elements.
*
* @internal
*/
var ListBlocksIterable = class {
_listHead;
/**
* @param listHead The head element of a list.
*/
constructor(listHead) {
this._listHead = listHead;
}
/**
* List blocks iterator.
*
* Iterates over all blocks of a list.
*/
[Symbol.iterator]() {
return new SiblingListBlocksIterator(this._listHead);
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* The list item ID generator.
*
* @internal
*/
var ListItemUid = class {
/**
* Returns the next ID.
*
* @internal
*/
/* istanbul ignore next: static function definition -- @preserve */
static next() {
return uid();
}
};
/**
* Returns true if the given model node is a list item block.
*
* @internal
*/
function isListItemBlock(node) {
return !!node && node.is("element") && node.hasAttribute("listItemId");
}
/**
* Returns an array with all elements that represents the same list item.
*
* It means that values for `listIndent`, and `listItemId` for all items are equal.
*
* @internal
* @param listItem Starting list item element.
* @param options.higherIndent Whether blocks with a higher indent level than the start block should be included
* in the result.
*/
function getAllListItemBlocks(listItem, options = {}) {
return [...getListItemBlocks(listItem, {
...options,
direction: "backward"
}), ...getListItemBlocks(listItem, {
...options,
direction: "forward"
})];
}
/**
* Returns an array with elements that represents the same list item in the specified direction.
*
* It means that values for `listIndent` and `listItemId` for all items are equal.
*
* **Note**: For backward search the provided item is not included, but for forward search it is included in the result.
*
* @internal
* @param listItem Starting list item element.
* @param options.direction Walking direction.
* @param options.higherIndent Whether blocks with a higher indent level than the start block should be included in the result.
*/
function getListItemBlocks(listItem, options = {}) {
const isForward = options.direction == "forward";
const items = Array.from(new ListWalker(listItem, {
...options,
includeSelf: isForward,
sameIndent: true,
sameAttributes: "listItemId"
}));
return isForward ? items : items.reverse();
}
/**
* Returns a list items nested inside the given list item.
*
* @internal
*/
function getNestedListBlocks(listItem) {
return Array.from(new ListWalker(listItem, {
direction: "forward",
higherIndent: true
}));
}
/**
* Returns array of all blocks/items of the same list as given block (same indent, same type and properties).
*
* @internal
* @param listItem Starting list item element.
* @param options Additional list walker options to modify the range of returned list items.
*/
function getListItems(listItem, options) {
const backwardBlocks = new ListWalker(listItem, {
sameIndent: true,
sameAttributes: "listType",
...options
});
const forwardBlocks = new ListWalker(listItem, {
sameIndent: true,
sameAttributes: "listType",
includeSelf: true,
direction: "forward",
...options
});
return [...Array.from(backwardBlocks).reverse(), ...forwardBlocks];
}
/**
* Check if the given block is the first in the list item.
*
* @internal
* @param listBlock The list block element.
*/
function isFirstBlockOfListItem(listBlock) {
if (!ListWalker.first(listBlock, {
sameIndent: true,
sameAttributes: "listItemId"
})) return true;
return false;
}
/**
* Check if the given block is the last in the list item.
*
* @internal
*/
function isLastBlockOfListItem(listBlock) {
if (!ListWalker.first(listBlock, {
direction: "forward",
sameIndent: true,
sameAttributes: "listItemId"
})) return true;
return false;
}
/**
* Expands the given list of selected blocks to include the leading and tailing blocks of partially selected list items.
*
* @internal
* @param blocks The list of selected blocks.
* @param options.withNested Whether should include nested list items.
*/
function expandListBlocksToCompleteItems(blocks, options = {}) {
blocks = toArray(blocks);
const higherIndent = options.withNested !== false;
const allBlocks = /* @__PURE__ */ new Set();
for (const block of blocks) for (const itemBlock of getAllListItemBlocks(block, { higherIndent })) allBlocks.add(itemBlock);
return sortBlocks(allBlocks);
}
/**
* Expands the given list of selected blocks to include all the items of the lists they're in.
*
* @internal
* @param blocks The list of selected blocks.
*/
function expandListBlocksToCompleteList(blocks) {
blocks = toArray(blocks);
const allBlocks = /* @__PURE__ */ new Set();
for (const block of blocks) for (const itemBlock of getListItems(block)) allBlocks.add(itemBlock);
return sortBlocks(allBlocks);
}
/**
* Splits the list item just before the provided list block.
*
* @internal
* @param listBlock The list block element.
* @param writer The model writer.
* @returns The array of updated blocks.
*/
function splitListItemBefore(listBlock, writer) {
const blocks = getListItemBlocks(listBlock, { direction: "forward" });
const id = ListItemUid.next();
for (const block of blocks) writer.setAttribute("listItemId", id, block);
return blocks;
}
/**
* Merges the list item with the parent list item.
*
* @internal
* @param listBlock The list block element.
* @param parentBlock The list block element to merge with.
* @param writer The model writer.
* @returns The array of updated blocks.
*/
function mergeListItemBefore(listBlock, parentBlock, writer) {
const attributes = {};
for (const [key, value] of parentBlock.getAttributes()) if (key.startsWith("list")) attributes[key] = value;
const blocks = getListItemBlocks(listBlock, { direction: "forward" });
for (const block of blocks) writer.setAttributes(attributes, block);
return blocks;
}
/**
* Increases indentation of given list blocks.
*
* @internal
* @param blocks The block or iterable of blocks.
* @param writer The model writer.
* @param options Additional options.
* @param options.expand Whether should expand the list of blocks to include complete list items.
* @param options.indentBy The number of levels the indentation should change (could be negative).
* @param options.attributeNames List of attribute names to remove when a block leaves the list (when blockIndent < 0).
*/
function indentBlocks(blocks, writer, { expand, indentBy = 1, attributeNames }) {
blocks = toArray(blocks);
const allBlocks = expand ? expandListBlocksToCompleteItems(blocks) : blocks;
for (const block of allBlocks) {
const blockIndent = block.getAttribute("listIndent") + indentBy;
if (blockIndent < 0) removeListAttributes(block, writer, attributeNames);
else writer.setAttribute("listIndent", blockIndent, block);
}
return allBlocks;
}
/**
* Decreases indentation of given list of blocks. If the indentation of some blocks matches the indentation
* of surrounding blocks, they get merged together.
*
* @internal
* @param blocks The block or iterable of blocks.
* @param writer The model writer.
* @param options Additional options.
* @param options.attributeNames List of attribute names to remove when a block leaves the list (when blockIndent < 0).
*/
function outdentBlocksWithMerge(blocks, writer, { attributeNames }) {
blocks = toArray(blocks);
const allBlocks = expandListBlocksToCompleteItems(blocks);
const visited = /* @__PURE__ */ new Set();
const referenceIndent = Math.min(...allBlocks.map((block) => block.getAttribute("listIndent")));
const parentBlocks = /* @__PURE__ */ new Map();
for (const block of allBlocks) parentBlocks.set(block, ListWalker.first(block, { lowerIndent: true }));
for (const block of allBlocks) {
if (visited.has(block)) continue;
visited.add(block);
const blockIndent = block.getAttribute("listIndent") - 1;
if (blockIndent < 0) {
removeListAttributes(block, writer, attributeNames);
continue;
}
if (block.getAttribute("listIndent") == referenceIndent && parentBlocks.get(block)) {
const mergedBlocks = mergeListItemIfNotLast(block, parentBlocks.get(block), writer);
for (const mergedBlock of mergedBlocks) visited.add(mergedBlock);
if (mergedBlocks.length) continue;
}
writer.setAttribute("listIndent", blockIndent, block);
}
return sortBlocks(visited);
}
/**
* Removes all list attributes from the given blocks.
*
* @internal
* @param blocks The block or iterable of blocks.
* @param writer The model writer.
* @param attributeNames List of attribute names to remove.
* @returns Array of altered blocks.
*/
function removeListAttributes(blocks, writer, attributeNames) {
blocks = toArray(blocks);
for (const block of blocks) if (block.is("element", "listItem")) writer.rename(block, "paragraph");
for (const block of blocks) for (const attributeKey of block.getAttributeKeys()) if (attributeNames.includes(attributeKey)) writer.removeAttribute(attributeKey, block);
return blocks;
}
/**
* Checks whether the given blocks are related to a single list item.
*
* @internal
* @param blocks The list block elements.
*/
function isSingleListItem(blocks) {
if (!blocks.length) return false;
const firstItemId = blocks[0].getAttribute("listItemId");
if (!firstItemId) return false;
return !blocks.some((item) => item.getAttribute("listItemId") != firstItemId);
}
/**
* Modifies the indents of list blocks following the given list block so the indentation is valid after
* the given block is no longer a list item.
*
* @internal
* @param lastBlock The last list block that has become a non-list element.
* @param writer The model writer.
* @returns Array of altered blocks.
*/
function outdentFollowingItems(lastBlock, writer) {
const changedBlocks = [];
let currentIndent = Number.POSITIVE_INFINITY;
for (const { node } of new SiblingListBlocksIterator(lastBlock.nextSibling)) {
const indent = node.getAttribute("listIndent");
if (indent == 0) break;
if (indent < currentIndent) currentIndent = indent;
const newIndent = indent - currentIndent;
writer.setAttribute("listIndent", newIndent, node);
changedBlocks.push(node);
}
return changedBlocks;
}
/**
* Returns the array of given blocks sorted by model indexes (document order).
*
* @internal
*/
function sortBlocks(blocks) {
return Array.from(blocks).filter((block) => block.root.rootName !== "$graveyard").sort((a, b) => a.index - b.index);
}
/**
* Returns a selected block object. If a selected object is inline or when there is no selected
* object, `null` is returned.
*
* @internal
* @param model The instance of editor model.
* @returns Selected block object or `null`.
*/
function getSelectedBlockObject(model) {
const selectedElement = model.document.selection.getSelectedElement();
if (!selectedElement) return null;
if (model.schema.isObject(selectedElement) && model.schema.isBlock(selectedElement)) return selectedElement;
return null;
}
/**
* Checks whether the given block can be replaced by a listItem.
*
* Note that this is possible only when multiBlock = false option is set in feature config.
*
* @param block A block to be tested.
* @param schema The schema of the document.
* @internal
*/
function canBecomeSimpleListItem(block, schema) {
return schema.checkChild(block.parent, "listItem") && schema.checkChild(block, "$text") && !schema.isObject(block);
}
/**
* Returns true if listType is of type `numbered` or `customNumbered`.
*
* @internal
*/
function isNumberedListType(listType) {
return listType == "numbered" || listType == "customNumbered";
}
/**
* Checks if the given list item block is the first block of the first item in its list at the given indent.
*
* Walks back over previous siblings and returns:
* - `true` if it reaches a non-list block or a list block at a lower indent (a new list begins here),
* - `false` if it finds a same-indent block of the same `listItemId` (a continuation of the current item) or of the same
* `listType` (the visible list already has earlier items),
* - `true` if it finds a same-indent block of a different `listType` and a different `listItemId` (a different list ends; ours
* starts here),
* - `false` if the loop ends (it reaches the first non-list-item block, or no more previous siblings) while passing only
* higher-indent blocks (those blocks live inside an intermediate skip-level `<li style="list-style-type:none">` wrapper
* at our indent).
*
* For example, in the model:
*
* ```
* ' # aaa'
* '# bbb'
* ```
*
* `bbb` is preceded by a higher-indent block `aaa`, which in the view is rendered inside an intermediate
* skip-level wrapper at indent 0:
*
* ```html
* <ol>
* <li style="list-style-type:none">
* <ol>
* <li>aaa</li>
* </ol>
* </li>
* <li>bbb</li>
* </ol>
* ```
*
* So `bbb` is the second visible item in the outer list and the function returns `false`.
*/
function isFirstListItemInList(listItem) {
const itemIndent = listItem.getAttribute("listIndent");
const itemListType = listItem.getAttribute("listType");
const itemListItemId = listItem.getAttribute("listItemId");
let previous = listItem.previousSibling;
let sawHigherIndent = false;
while (isListItemBlock(previous)) {
const previousIndent = previous.getAttribute("listIndent");
if (previousIndent < itemIndent) return true;
if (previousIndent === itemIndent) {
if (previous.getAttribute("listItemId") === itemListItemId) return false;
return previous.getAttribute("listType") !== itemListType;
}
sawHigherIndent = true;
previous = previous.previousSibling;
}
return !sawHigherIndent;
}
/**
* Merges a given block to the given parent block if parent is a list item and there is no more blocks in the same item.
*/
function mergeListItemIfNotLast(block, parentBlock, writer) {
if (getListItemBlocks(parentBlock, { direction: "forward" }).pop().index > block.index) return mergeListItemBefore(block, parentBlock, writer);
return [];
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module list/list/listindentcommand
*/
/**
* The document list indent command. It is used by the {@link module:list/list~List list feature}.
*/
var ListIndentCommand = class extends Command {
/**
* Determines by how much the command will change the list item's indent attribute.
*/
_direction;
/**
* Creates an instance of the command.
*
* @param editor The editor instance.
* @param indentDirection The direction of indent. If it is equal to `backward`, the command
* will outdent a list item.
*/
constructor(editor, indentDirection) {
super(editor);
this._direction = indentDirection;
}
/**
* @inheritDoc
*/
refresh() {
this.isEnabled = this._checkEnabled();
}
/**
* Indents or outdents (depending on the {@link #constructor}'s `indentDirection` parameter) selected list items.
*
* @fires execute
* @fires afterExecute
*/
execute() {
const editor = this.editor;
const model = editor.model;
const blocks = getSelectedListBlocks(model.document.selection);
const attributeNames = editor.plugins.get("ListEditing").getListAttributeNames();
model.change((writer) => {
const changedBlocks = [];
if (isSingleListItem(blocks) && !isFirstBlockOfListItem(blocks[0])) {
if (this._direction == "forward") changedBlocks.push(...indentBlocks(blocks, writer, { attributeNames }));
changedBlocks.push(...splitListItemBefore(blocks[0], writer));
} else if (this._direction == "forward") changedBlocks.push(...indentBlocks(blocks, writer, {
expand: true,
attributeNames
}));
else changedBlocks.push(...outdentBlocksWithMerge(blocks, writer, { attributeNames }));
for (const block of changedBlocks) {
if (!block.hasAttribute("listType")) continue;
const previousItemBlock = ListWalker.first(block, { sameIndent: true });
if (previousItemBlock) writer.setAttribute("listType", previousItemBlock.getAttribute("listType"), block);
}
this._fireAfterExecute(changedBlocks);
});
}
/**
* Fires the `afterExecute` event.
*
* @param changedBlocks The changed list elements.
*/
_fireAfterExecute(changedBlocks) {
this.fire("afterExecute", sortBlocks(new Set(changedBlocks)));
}
/**
* Checks whether the command can be enabled in the current context.
*
* @returns Whether the command should be enabled.
*/
_checkEnabled() {
let blocks = getSelectedListBlocks(this.editor.model.document.selection);
let firstBlock = blocks[0];
if (!firstBlock) return false;
if (this._direction == "backward") return true;
if (isSingleListItem(blocks) && !isFirstBlockOfListItem(blocks[0])) return true;
if (this.editor.config.get("list.enableSkipLevelLists")) return true;
blocks = expandListBlocksToCompleteItems(blocks);
firstBlock = blocks[0];
const siblingItem = ListWalker.first(firstBlock, { sameIndent: true });
if (!siblingItem) return false;
if (siblingItem.getAttribute("listType") == firstBlock.getAttribute("listType")) return true;
return false;
}
};
/**
* Returns an array of selected blocks truncated to the first non list block element.
*/
function getSelectedListBlocks(selection) {
const blocks = Array.from(selection.getSelectedBlocks());
const firstNonListBlockIndex = blocks.findIndex((block) => !isListItemBlock(block));
if (firstNonListBlockIndex != -1) blocks.length = firstNonListBlockIndex;
return blocks;
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* The list command. It is used by the {@link module:list/list~List list feature}.
*/
var ListCommand = class extends Command {
/**
* The type of the list created by the command.
*/
type;
/**
* List Walker options that change the range of the list items to be changed when the selection is collapsed within a list item.
*
* In a multi-level list, when the selection is collapsed within a list item, instead of changing only the list items of the same list
* type and current indent level, the entire list structure is changed (all list items at all indent levels of any list type).
*/
_listWalkerOptions;
/**
* Creates an instance of the command.
*
* @param editor The editor instance.
* @param type List type that will be handled by this command.
*/
constructor(editor, type, options = {}) {
super(editor);
this.type = type;
this._listWalkerOptions = options.multiLevel ? {
higherIndent: true,
lowerIndent: true,
sameAttributes: []
} : void 0;
}
/**
* @inheritDoc
*/
refresh() {
this.value = this._getValue();
this.isEnabled = this._checkEnabled();
}
/**
* Executes the list command.
*
* @fires execute
* @fires afterExecute
* @param options Command options.
* @param options.forceValue If set, it will force the command behavior. If `true`, the command will try to convert the
* selected items and potentially the neighbor elements to the proper list items. If set to `false` it will convert selected elements
* to paragraphs. If not set, the command will toggle selected elements to list items or paragraphs, depending on the selection.
* @param options.additionalAttributes Additional attributes that are set for list items when the command is executed.
*/
execute(options = {}) {
const model = this.editor.model;
const document = model.document;
const selectedBlockObject = getSelectedBlockObject(model);
const blocks = Array.from(document.selection.getSelectedBlocks()).filter((block) => model.schema.checkAttribute(block, "listType") || canBecomeSimpleListItem(block, model.schema));
const turnOff = options.forceValue !== void 0 ? !options.forceValue : this.value;
model.change((writer) => {
if (turnOff) {
const lastBlock = blocks[blocks.length - 1];
const attributeNames = this.editor.plugins.get("ListEditing").getListAttributeNames();
const itemBlocks = getListItemBlocks(lastBlock, { direction: "forward" });
const changedBlocks = [];
if (itemBlocks.length > 1) changedBlocks.push(...splitListItemBefore(itemBlocks[1], writer));
changedBlocks.push(...removeListAttributes(blocks, writer, attributeNames));
changedBlocks.push(...outdentFollowingItems(lastBlock, writer));
this._fireAfterExecute(changedBlocks);
} else if ((selectedBlockObject || document.selection.isCollapsed) && isListItemBlock(blocks[0])) {
const changedBlocks = getListItems(selectedBlockObject || blocks[0], this._listWalkerOptions);
for (const block of changedBlocks) writer.setAttributes({
...options.additionalAttributes,
listType: this.type
}, block);
this._fireAfterExecute(changedBlocks);
} else {
const changedBlocks = [];
for (const block of blocks) if (!block.hasAttribute("listType")) {
if (!block.is("element", "listItem") && canBecomeSimpleListItem(block, model.schema)) writer.rename(block, "listItem");
writer.setAttributes({
...options.additionalAttributes,
listIndent: 0,
listItemId: ListItemUid.next(),
listType: this.type
}, block);
changedBlocks.push(block);
} else for (const node of expandListBlocksToCompleteItems(block, { withNested: false })) if (node.getAttribute("listType") != this.type) {
writer.setAttributes({
...options.additionalAttributes,
listType: this.type
}, node);
changedBlocks.push(node);
}
this._fireAfterExecute(changedBlocks);
}
});
}
/**
* Fires the `afterExecute` event.
*
* @param changedBlocks The changed list elements.
*/
_fireAfterExecute(changedBlocks) {
this.fire("afterExecute", sortBlocks(new Set(changedBlocks)));
}
/**
* Checks the command's {@link #value}.
*
* @returns The current value.
*/
_getValue() {
const selection = this.editor.model.document.selection;
const blocks = Array.from(selection.getSelectedBlocks());
if (!blocks.length) return false;
for (const block of blocks) if (block.getAttribute("listType") != this.type) return false;
return true;
}
/**
* Checks whether the command can be enabled in the current context.
*
* @returns Whether the command should be enabled.
*/
_checkEnabled() {
const model = this.editor.model;
const schema = model.schema;
const selection = model.document.selection;
const blocks = Array.from(selection.getSelectedBlocks());
if (!blocks.length) return false;
if (this.value) return true;
for (const block of blocks) if (schema.checkAttribute(block, "listType") || canBecomeSimpleListItem(block, schema)) return true;
return false;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module list/list/listmergecommand
*/
/**
* The document list merge command. It is used by the {@link module:list/list~List list feature}.
*/
var ListMergeCommand = class extends Command {
/**
* Whether list item should be merged before or after the selected block.
*/
_direction;
/**
* Creates an instance of the command.
*
* @param editor The editor instance.
* @param direction Whether list item should be merged before or after the selected block.
*/
constructor(editor, direction) {
super(editor);
this._direction = direction;
}
/**
* @inheritDoc
*/
refresh() {
this.isEnabled = this._checkEnabled();
}
/**
* Merges list blocks together (depending on the {@link #constructor}'s `direction` parameter).
*
* @fires execute
* @fires afterExecute
* @param options Command options.
* @param options.shouldMergeOnBlocksContentLevel When set `true`, merging will be performed together
* with {@link module:engine/model/model~Model#deleteContent} 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.
*/
execute({ shouldMergeOnBlocksContentLevel = false } = {}) {
const editor = this.editor;
const model = editor.model;
const selection = model.document.selection;
const changedBlocks = [];
const attributeNames = editor.plugins.get("ListEditing").getListAttributeNames();
model.change((writer) => {
const { firstElement, lastElement } = this._getMergeSubjectElements(selection, shouldMergeOnBlocksContentLevel);
if (!firstElement || !lastElement) return;
const firstIndent = firstElement.getAttribute("listIndent") || 0;
const lastIndent = lastElement.getAttribute("listIndent");
const lastElementId = lastElement.getAttribute("listItemId");
if (firstIndent != lastIndent) {
const nestedLastElementBlocks = getNestedListBlocks(lastElement);
changedBlocks.push(...indentBlocks([lastElement, ...nestedLastElementBlocks], writer, {
indentBy: firstIndent - lastIndent,
expand: firstIndent < lastIndent,
attributeNames
}));
}
if (shouldMergeOnBlocksContentLevel) {
let sel = selection;
if (selection.isCollapsed) sel = writer.createSelection(writer.createRange(writer.createPositionAt(firstElement, "end"), writer.createPositionAt(lastElement, 0)));
model.deleteContent(sel, { doNotResetEntireContent: selection.isCollapsed });
const lastElementAfterDelete = sel.getLastPosition().parent;
const nextSibling = lastElementAfterDelete.nextSibling;
changedBlocks.push(lastElementAfterDelete);
if (nextSibling && nextSibling !== lastElement && nextSibling.getAttribute("listItemId") == lastElementId) changedBlocks.push(...mergeListItemBefore(nextSibling, lastElementAfterDelete, writer));
} else changedBlocks.push(...mergeListItemBefore(lastElement, firstElement, writer));
this._fireAfterExecute(changedBlocks);
});
}
/**
* Fires the `afterExecute` event.
*
* @param changedBlocks The changed list elements.
*/
_fireAfterExecute(changedBlocks) {
this.fire("afterExecute", sortBlocks(new Set(changedBlocks)));
}
/**
* Checks whether the command can be enabled in the current context.
*
* @returns Whether the command should be enabled.
*/
_checkEnabled() {
const model = this.editor.model;
const selection = model.document.selection;
const selectedBlockObject = getSelectedBlockObject(model);
if (selection.isCollapsed || selectedBlockObject) {
const positionParent = selectedBlockObject || selection.getFirstPosition().parent;
if (!isListItemBlock(positionParent)) return false;
const siblingNode = this._direction == "backward" ? positionParent.previousSibling : positionParent.nextSibling;
if (!siblingNode) return false;
if (isSingleListItem([positionParent, siblingNode])) return false;
} else {
const lastPosition = selection.getLastPosition();
const firstPosition = selection.getFirstPosition();
if (lastPosition.parent === firstPosition.parent) return false;
if (!isListItemBlock(lastPosition.parent)) return false;
}
return true;
}
/**
* Returns the boundary elements the merge should be executed for. These are not necessarily selection's first
* and last position parents but sometimes sibling or even further blocks depending on the context.
*
* @param selection The selection the merge is executed for.
* @param shouldMergeOnBlocksContentLevel When `true`, merge is performed together with
* {@link module:engine/model/model~Model#deleteContent} to remove the inline content within the selection.
*/
_getMergeSubjectElements(selection, shouldMergeOnBlocksContentLevel) {
const model = this.editor.model;
const selectedBlockObject = getSelectedBlockObject(model);
let firstElement, lastElement;
if (selection.isCollapsed || selectedBlockObject) {
const positionParent = selectedBlockObject || selection.getFirstPosition().parent;
const isFirstBlock = isFirstBlockOfListItem(positionParent);
if (this._direction == "backward") {
lastElement = positionParent;
if (isFirstBlock && !shouldMergeOnBlocksContentLevel) {
firstElement = ListWalker.first(positionParent, {
sameIndent: true,
lowerIndent: true
});
if (!firstElement && isListItemBlock(positionParent.previousSibling)) firstElement = positionParent.previousSibling;
} else firstElement = positionParent.previousSibling;
} else {
firstElement = positionParent;
lastElement = positionParent.nextSibling;
}
} else {
firstElement = selection.getFirstPosition().parent;
lastElement = selection.getLastPosition().parent;
}
return {
firstElement,
lastElement
};
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* The document list split command that splits the list item at the selection.
*
* It is used by the {@link module:list/list~List list feature}.
*/
var ListSplitCommand = class extends Command {
/**
* Whether list item should be split before or after the selected block.
*/
_direction;
/**
* Creates an instance of the command.
*
* @param editor The editor instance.
* @param direction Whether list item should be split before or after the selected block.
*/
constructor(editor, direction) {
super(editor);
this._direction = direction;
}
/**
* @inheritDoc
*/
refresh() {
this.isEnabled = this._checkEnabled();
}
/**
* Splits the list item at the selection.
*
* @fires execute
* @fires afterExecute
*/
execute() {
this.editor.model.change((writer) => {
const changedBlocks = splitListItemBefore(this._getStartBlock(), writer);
this._fireAfterExecute(changedBlocks);
});
}
/**
* Fires the `afterExecute` event.
*
* @param changedBlocks The changed list elements.
*/
_fireAfterExecute(changedBlocks) {
this.fire("afterExecute", sortBlocks(new Set(changedBlocks)));
}
/**
* Checks whether the command can be enabled in the current context.
*
* @returns Whether the command should be enabled.
*/
_checkEnabled() {
const selection = this.editor.model.document.selection;
const block = this._getStartBlock();
return selection.isCollapsed && isListItemBlock(block) && !isFirstBlockOfListItem(block);
}
/**
* Returns the model element that is the main focus of the command (according to the current selection and command direction).
*/
_getStartBlock() {
const positionParent = this.editor.model.document.selection.getFirstPosition().parent;
return this._direction == "before" ? positionParent : positionParent.nextSibling;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module list/listformatting/listitemboldintegration
*/
/**
* The list item bold integration plugin.
*/
var ListItemBoldIntegration = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "ListItemBoldIntegration";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [ListEditing];
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const ListFormatting = editor.plugins.get("ListFormatting");
const listEditing = editor.plugins.get(ListEditing);
if (!editor.plugins.has("BoldEditing") || !this.editor.config.get("list.enableListItemMarkerFormatting")) return;
ListFormatting.registerFormatAttribute("bold", "listItemBold");
listEditing.registerDowncastStrategy({
scope: "item",
attributeName: "listItemBold",
setAttributeOnDowncast(writer, value, viewElement, options) {
/* v8 ignore next -- Downcast callbacks are only registered for meaningful list marker formatting values. */
if (value) {
writer.addClass("ck-list-marker-bold", viewElement);
if (env.isSafari && !(options && options.dataPipeline)) writer.setStyle("--ck-content-list-marker-dummy-bold", "0", viewElement);
}
}
});
}
/**
* @inheritDoc
*/
afterInit() {
const editor = this.editor;
const model = editor.model;
if (!editor.plugins.has("BoldEditing") || !this.editor.config.get("list.enableListItemMarkerFormatting")) return;
model.schema.extend("$listItem", { allowAttributes: "listItemBold" });
model.schema.setAttributeProperties("listItemBold", { isFormatting: true });
model.schema.addAttributeCheck((context) => {
if (!context.last.getAttribute("listItemId")) return false;
}, "listItemBold");
editor.conversion.for("upcast").attributeToAttribute({
model: "listItemBold",
view: {
name: "li",
classes: "ck-list-marker-bold"
}
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module list/listformatting/listitemitalicintegration
*/
/**
* The list item italic integration plugin.
*/
var ListItemItalicIntegration = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "ListItemItalicIntegration";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [ListEditing];
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const ListFormatting = editor.plugins.get("ListFormatting");
const listEditing = editor.plugins.get(ListEditing);
if (!editor.plugins.has("ItalicEditing") || !this.editor.config.get("list.enableListItemMarkerFormatting")) return;
ListFormatting.registerFormatAttribute("italic", "listItemItalic");
listEditing.registerDowncastStrategy({
scope: "item",
attributeName: "listItemItalic",
setAttributeOnDowncast(writer, value, viewElement, options) {
/* v8 ignore next -- Downcast callbacks are only registered for meaningful list marker formatting values. */
if (value) {
writer.addClass("ck-list-marker-italic", viewElement);
if (env.isSafari && !(options && options.dataPipeline)) writer.setStyle("--ck-content-list-marker-dummy-italic", "0", viewElement);
}
}
});
}
/**
* @inheritDoc
*/
afterInit() {
const editor = this.editor;
const model = editor.model;
if (!editor.plugins.has("ItalicEditing") || !this.editor.config.get("list.enableListItemMarkerFormatting")) return;
model.schema.extend("$listItem", { allowAttributes: "listItemItalic" });
model.schema.setAttributeProperties("listItemItalic", { isFormatting: true });
model.schema.addAttributeCheck((context) => {
if (!context.last.getAttribute("listItemId")) return false;
}, "listItemItalic");
editor.conversion.for("upcast").attributeToAttribute({
model: "listItemItalic",
view: {
name: "li",
classes: "ck-list-marker-italic"
}
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module list/listformatting/listitemfontsizeintegration
*/
/**
* The list item font size integration plugin.
*/
var ListItemFontSizeIntegration = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "ListItemFontSizeIntegration";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [ListEditing];
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const ListFormatting = editor.plugins.get("ListFormatting");
const listEditing = editor.plugins.get(ListEditing);
if (!editor.plugins.has("FontSizeEditing") || !this.editor.config.get("list.enableListItemMarkerFormatting")) return;
const normalizedFontSizeOptions = _normalizeFontSizeOptions(editor.config.get("fontSize.options"));
ListFormatting.registerFormatAttribute("fontSize", "listItemFontSize");
listEditing.registerDowncastStrategy({
scope: "item",
attributeName: "listItemFontSize",
setAttributeOnDowncast(writer, value, viewElement, options) {
/* v8 ignore next -- Downcast callbacks are only registered for meaningful list marker formatting values. */
if (value) {
const fontSizeOption = normalizedFontSizeOptions.find((option) => option.model == value);
if (fontSizeOption && fontSizeOption.view && typeof fontSizeOption.view != "string") {
if (fontSizeOption.view.styles) {
writer.addClass("ck-list-marker-font-size", viewElement);
writer.setStyle("--ck-content-list-marker-font-size", fontSizeOption.view.styles["font-size"], viewElement);
} else if (fontSizeOption.view.classes) {
writer.addClass(`ck-list-marker-font-size-${value}`, viewElement);
if (env.isSafari && !(options && options.dataPipeline)) writer.setStyle("--ck-content-list-marker-dummy-font-size", "0", viewElement);
}
} else {
writer.addClass("ck-list-marker-font-size", viewElement);
writer.setStyle("--ck-content-list-marker-font-size", value, viewElement);
}
}
}
});
}
/**
* @inheritDoc
*/
afterInit() {
const editor = this.editor;
const model = editor.model;
if (!editor.plugins.has("FontSizeEditing") || !this.editor.config.get("list.enableListItemMarkerFormatting")) return;
model.schema.extend("$listItem", { allowAttributes: "listItemFontSize" });
model.schema.setAttributeProperties("listItemFontSize", { isFormatting: true });
model.schema.addAttributeCheck((context) => {
if (!context.last.getAttribute("listItemId")) return false;
}, "listItemFontSize");
editor.conversion.for("upcast").elementToAttribute({
model: {
key: "listItemFontSize",
value: (viewElement) => viewElement.getStyle("--ck-content-list-marker-font-size")
},
view: {
name: "li",
classes: "ck-list-marker-font-size",
styles: { "--ck-content-list-marker-font-size": /.*/ }
}
});
const fontSizeOptions = _normalizeFontSizeOptions(editor.config.get("fontSize.options"));
for (const option of fontSizeOptions)
/* v8 ignore next -- Normalized font size options used by the integration provide model and view values. */
if (option.model && option.view) editor.conversion.for("upcast").elementToAttribute({
model: {
key: "listItemFontSize",
value: option.model
},
view: {
name: "li",
classes: `ck-list-marker-font-size-${option.model}`
}
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module list/listformatting/listitemfontcolorintegration
*/
/**
* The list item font color integration plugin.
*/
var ListItemFontColorIntegration = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "ListItemFontColorIntegration";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [ListEditing];
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const ListFormatting = editor.plugins.get("ListFormatting");
const listEditing = editor.plugins.get(ListEditing);
if (!editor.plugins.has("FontColorEditing") || !this.editor.config.get("list.enableListItemMarkerFormatting")) return;
ListFormatting.registerFormatAttribute("fontColor", "listItemFontColor");
listEditing.registerDowncastStrategy({
scope: "item",
attributeName: "listItemFontColor",
setAttributeOnDowncast(writer, value, viewElement) {
/* v8 ignore next -- Downcast callbacks are only registered for meaningful list marker formatting values. */
if (value) {
writer.addClass("ck-list-marker-color", viewElement);
writer.setStyle("--ck-content-list-marker-color", value, viewElement);
}
}
});
}
/**
* @inheritDoc
*/
afterInit() {
const editor = this.editor;
const model = editor.model;
if (!editor.plugins.has("FontColorEditing") || !this.editor.config.get("list.enableListItemMarkerFormatting")) return;
model.schema.extend("$listItem", { allowAttributes: "listItemFontColor" });
model.schema.setAttributeProperties("listItemFontColor", { isFormatting: true });
model.schema.addAttributeCheck((context) => {
if (!context.last.getAttribute("listItemId")) return false;
}, "listItemFontColor");
editor.conversion.for("upcast").attributeToAttribute({
model: {
key: "listItemFontColor",
value: (viewElement) => {
return viewElement.getStyle("--ck-content-list-marker-color");
}
},
view: {
name: "li",
classes: "ck-list-marker-color",
styles: { "--ck-content-list-marker-color": /.*/ }
}
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module list/listformatting/listitemfontfamilyintegration
*/
/**
* The list item font family integration plugin.
*/
var ListItemFontFamilyIntegration = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "ListItemFontFamilyIntegration";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [ListEditing];
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const ListFormatting = editor.plugins.get("ListFormatting");
const listEditing = editor.plugins.get(ListEditing);
if (!editor.plugins.has("FontFamilyEditing") || !this.editor.config.get("list.enableListItemMarkerFormatting")) return;
ListFormatting.registerFormatAttribute("fontFamily", "listItemFontFamily");
listEditing.registerDowncastStrategy({
scope: "item",
attributeName: "listItemFontFamily",
setAttributeOnDowncast(writer, value, viewElement) {
/* v8 ignore next -- Downcast callbacks are only registered for meaningful list marker formatting values. */
if (value) {
writer.addClass("ck-list-marker-font-family", viewElement);
writer.setStyle("--ck-content-list-marker-font-family", value, viewElement);
}
}
});
}
/**
* @inheritDoc
*/
afterInit() {
const editor = this.editor;
const model = editor.model;
if (!editor.plugins.has("FontFamilyEditing") || !this.editor.config.get("list.enableListItemMarkerFormatting")) return;
model.schema.extend("$listItem", { allowAttributes: "listItemFontFamily" });
model.schema.setAttributeProperties("listItemFontFamily", { isFormatting: true });
model.schema.addAttributeCheck((context) => {
if (!context.last.getAttribute("listItemId")) return false;
}, "listItemFontFamily");
editor.conversion.for("upcast").attributeToAttribute({
model: {
key: "listItemFontFamily",
value: (viewElement) => {
return viewElement.getStyle("--ck