UNPKG

@ckeditor/ckeditor5-list

Version:

Ordered and unordered lists feature to CKEditor 5.

249 lines (248 loc) • 10.6 kB
/** * @license Copyright (c) 2003-2025, 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 */ import { Plugin } from 'ckeditor5/src/core.js'; import { ListItemBoldIntegration } from './listformatting/listitemboldintegration.js'; import { ListItemItalicIntegration } from './listformatting/listitemitalicintegration.js'; import { ListItemFontSizeIntegration } from './listformatting/listitemfontsizeintegration.js'; import { ListItemFontColorIntegration } from './listformatting/listitemfontcolorintegration.js'; import { ListItemFontFamilyIntegration } from './listformatting/listitemfontfamilyintegration.js'; import { isListItemBlock, getAllListItemBlocks, isFirstBlockOfListItem } from './list/utils/model.js'; import '../theme/listformatting.css'; /** * The list formatting plugin. * * It enables integration with formatting plugins to style the list marker. * The list marker is styled based on the consistent formatting applied to the content of the list item. * * The list of supported formatting plugins includes: * * Font color. * * Font size. * * Font family. * * Bold. * * Italic. */ export class ListFormatting extends Plugin { /** * The list of loaded formatting. */ _loadedFormatting = {}; /** * @inheritDoc */ static get pluginName() { return 'ListFormatting'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ static get requires() { return [ ListItemBoldIntegration, ListItemItalicIntegration, ListItemFontSizeIntegration, ListItemFontColorIntegration, ListItemFontFamilyIntegration ]; } /** * @inheritDoc */ constructor(editor) { super(editor); editor.config.define('list.enableListItemMarkerFormatting', true); } /** * @inheritDoc */ afterInit() { if (!this.editor.config.get('list.enableListItemMarkerFormatting')) { return; } this._registerPostfixerForListItemFormatting(); } /** * Registers a postfixer that ensures that the list item formatting attribute is consistent with the formatting * applied to the content of the list item. */ _registerPostfixerForListItemFormatting() { const model = this.editor.model; model.document.registerPostFixer(writer => { const changes = model.document.differ.getChanges(); const modifiedListItems = new Set(); let returnValue = false; for (const entry of changes) { if (entry.type === 'attribute') { if (entry.attributeKey == 'listItemId' || entry.attributeKey == 'listType' || this._isInlineOrSelectionFormatting(entry.attributeKey) || Object.values(this._loadedFormatting).includes(entry.attributeKey)) { if (isListItemBlock(entry.range.start.nodeAfter)) { modifiedListItems.add(entry.range.start.nodeAfter); } else if (isListItemBlock(entry.range.start.parent)) { modifiedListItems.add(entry.range.start.parent); } } } else { if (isListItemBlock(entry.position.nodeAfter)) { modifiedListItems.add(entry.position.nodeAfter); } if (isListItemBlock(entry.position.nodeBefore)) { modifiedListItems.add(entry.position.nodeBefore); } if (isListItemBlock(entry.position.parent)) { modifiedListItems.add(entry.position.parent); } if (entry.type == 'insert' && entry.name != '$text') { const range = writer.createRangeIn(entry.position.nodeAfter); for (const item of range.getItems()) { if (isListItemBlock(item)) { modifiedListItems.add(item); } } } } } for (const listItem of modifiedListItems) { const formats = getListItemConsistentFormat(model, listItem, Object.keys(this._loadedFormatting)); for (const [formatAttributeName, formatValue] of Object.entries(formats)) { const listItemFormatAttributeName = this._loadedFormatting[formatAttributeName]; if (formatValue && setFormattingToListItem(writer, listItem, listItemFormatAttributeName, formatValue)) { returnValue = true; } else if (!formatValue && removeFormattingFromListItem(writer, listItem, listItemFormatAttributeName)) { returnValue = true; } } } return returnValue; }); } /** * Registers an integration between a default attribute (e.g., `fontFamily`) and a new attribute * intended specifically for list item elements (e.g., `listItemFontFamily`). * * These attributes are later used by the postfixer logic to determine whether to add the new attribute * to the list item element, based on whether there is a consistent default formatting attribute * applied within its content. */ registerFormatAttribute(formatAttribute, listItemFormatAttribute) { this._loadedFormatting[formatAttribute] = listItemFormatAttribute; } /** * Returns true if the given model attribute name is a supported inline formatting attribute. */ _isInlineOrSelectionFormatting(attributeKey) { return attributeKey.replace(/^selection:/, '') in this._loadedFormatting; } } /** * Returns the consistent format of the list item element. * If the list item contains multiple blocks, it checks only the first block. */ function getListItemConsistentFormat(model, listItem, attributeKeys) { if (isFirstBlockOfListItem(listItem)) { return getSingleListItemConsistentFormat(model, listItem, attributeKeys); } // Always the first block of the list item should be checked for consistent formatting. const listItemBlocks = getAllListItemBlocks(listItem); return getSingleListItemConsistentFormat(model, listItemBlocks[0], attributeKeys); } /** * Returns the consistent format of a single list item element. */ function getSingleListItemConsistentFormat(model, listItem, attributeKeys) { // Only bulleted and numbered lists can have formatting (to-do lists are not supported). // Do not check internals of limit elements (for example, do not check table cells). if (!isNumberedOrBulletedList(listItem) || model.schema.isLimit(listItem)) { return Object.fromEntries(attributeKeys.map(attributeKey => [attributeKey])); } if (listItem.isEmpty) { return Object.fromEntries(attributeKeys.map(attributeKey => ([attributeKey, listItem.getAttribute(`selection:${attributeKey}`)]))); } const attributesToCheck = new Set(attributeKeys); const valuesMap = {}; const range = model.createRangeIn(listItem); const walker = range.getWalker({ ignoreElementEnd: true }); for (const { item } of walker) { for (const attributeKey of attributesToCheck) { if (model.schema.checkAttribute(item, attributeKey)) { const formatAttribute = item.getAttribute(attributeKey); if (formatAttribute === undefined) { attributesToCheck.delete(attributeKey); valuesMap[attributeKey] = undefined; } else if (valuesMap[attributeKey] === undefined) { // First item inside a list item block. valuesMap[attributeKey] = formatAttribute; } else if (valuesMap[attributeKey] !== formatAttribute) { // Following items in the same block of a list item. attributesToCheck.delete(attributeKey); valuesMap[attributeKey] = undefined; } } else if (!(attributeKey in valuesMap)) { // Store it so a format would be removed when all items in the given list item does not allow that formatting. valuesMap[attributeKey] = undefined; } } // End early if all attributes have been checked and are inconsistent. if (!attributesToCheck.size) { break; } // Jump over inline limit elements as we expect only outside them to be the same formatting. if (model.schema.isLimit(item)) { walker.jumpTo(model.createPositionAfter(item)); } } return valuesMap; } /** * Adds the specified formatting attribute to the list item element. */ function setFormattingToListItem(writer, listItem, attributeKey, attributeValue) { // Multi-block items should have consistent formatting. const listItemBlocks = getAllListItemBlocks(listItem); let wasChanged = false; for (const listItem of listItemBlocks) { if (!listItem.hasAttribute(attributeKey) || listItem.getAttribute(attributeKey) !== attributeValue) { writer.setAttribute(attributeKey, attributeValue, listItem); wasChanged = true; } } return wasChanged; } /** * Removes the specified formatting attribute from the list item element. */ function removeFormattingFromListItem(writer, listItem, attributeKey) { // Multi-block items should have consistent formatting. const listItemBlocks = getAllListItemBlocks(listItem); let wasChanged = false; for (const listItem of listItemBlocks) { if (listItem.hasAttribute(attributeKey)) { writer.removeAttribute(attributeKey, listItem); wasChanged = true; } } return wasChanged; } /** * Checks if the given list type is a numbered or bulleted list. */ function isNumberedOrBulletedList(listItem) { return ['numbered', 'bulleted', 'customNumbered', 'customBulleted'].includes(listItem.getAttribute('listType')); }