UNPKG

@ckeditor/ckeditor5-list

Version:

Ordered and unordered lists feature to CKEditor 5.

1,446 lines (1,435 loc) • 279 kB
/** * @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