UNPKG

@ckeditor/ckeditor5-list

Version:

Ordered and unordered lists feature to CKEditor 5.

516 lines (515 loc) • 23.1 kB
/** * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ /** * @module list/list/converters */ import { UpcastWriter } from 'ckeditor5/src/engine.js'; import { getAllListItemBlocks, getListItemBlocks, isListItemBlock, isFirstBlockOfListItem, ListItemUid } from './utils/model.js'; import { createListElement, createListItemElement, getIndent, isListView, isListItemView } from './utils/view.js'; import ListWalker, { iterateSiblingListBlocks } from './utils/listwalker.js'; import { findAndAddListHeadToMap } from './utils/postfixers.js'; /** * Returns the upcast converter for list items. It's supposed to work after the block converters (content inside list items) are converted. * * @internal */ export function listItemUpcastConverter() { return (evt, data, conversionApi) => { const { writer, schema } = conversionApi; if (!data.modelRange) { return; } const items = Array.from(data.modelRange.getItems({ shallow: true })) .filter((item) => schema.checkAttribute(item, 'listItemId')); if (!items.length) { return; } const listItemId = ListItemUid.next(); const listIndent = getIndent(data.viewItem); let listType = data.viewItem.parent && data.viewItem.parent.is('element', 'ol') ? 'numbered' : 'bulleted'; // Preserve list type if was already set (for example by to-do list feature). const firstItemListType = items[0].getAttribute('listType'); if (firstItemListType) { listType = firstItemListType; } const attributes = { listItemId, listIndent, listType }; for (const item of items) { // Set list attributes only on same level items, those nested deeper are already handled by the recursive conversion. if (!item.hasAttribute('listItemId')) { writer.setAttributes(attributes, item); } } if (items.length > 1) { // Make sure that list item that contain only nested list will preserve paragraph for itself: // <ul> // <li> // <p></p> <-- this one must be kept // <ul> // <li></li> // </ul> // </li> // </ul> if (items[1].getAttribute('listItemId') != attributes.listItemId) { conversionApi.keepEmptyElement(items[0]); } } }; } /** * Returns the upcast converter for the `<ul>` and `<ol>` view elements that cleans the input view of garbage. * This is mostly to clean whitespaces from between the `<li>` view elements inside the view list element. However, * incorrect data can also be cleared if the view was incorrect. * * @internal */ export function listUpcastCleanList() { return (evt, data, conversionApi) => { if (!conversionApi.consumable.test(data.viewItem, { name: true })) { return; } const viewWriter = new UpcastWriter(data.viewItem.document); for (const child of Array.from(data.viewItem.getChildren())) { if (!isListItemView(child) && !isListView(child)) { viewWriter.remove(child); } } }; } /** * Returns a model document change:data event listener that triggers conversion of related items if needed. * * @internal * @param model The editor model. * @param editing The editing controller. * @param attributeNames The list of all model list attributes (including registered strategies). * @param listEditing The document list editing plugin. */ export function reconvertItemsOnDataChange(model, editing, attributeNames, listEditing) { return () => { const changes = model.document.differ.getChanges(); const itemsToRefresh = []; const itemToListHead = new Map(); const changedItems = new Set(); for (const entry of changes) { if (entry.type == 'insert' && entry.name != '$text') { findAndAddListHeadToMap(entry.position, itemToListHead); // Insert of a non-list item. if (!entry.attributes.has('listItemId')) { findAndAddListHeadToMap(entry.position.getShiftedBy(entry.length), itemToListHead); } else { changedItems.add(entry.position.nodeAfter); } } // Removed list item. else if (entry.type == 'remove' && entry.attributes.has('listItemId')) { findAndAddListHeadToMap(entry.position, itemToListHead); } // Changed list attribute. else if (entry.type == 'attribute') { const item = entry.range.start.nodeAfter; if (attributeNames.includes(entry.attributeKey)) { findAndAddListHeadToMap(entry.range.start, itemToListHead); if (entry.attributeNewValue === null) { findAndAddListHeadToMap(entry.range.start.getShiftedBy(1), itemToListHead); // Check if paragraph should be converted from bogus to plain paragraph. if (doesItemBlockRequiresRefresh(item)) { itemsToRefresh.push(item); } } else { changedItems.add(item); } } else if (isListItemBlock(item)) { // Some other attribute was changed on the list item, // check if paragraph does not need to be converted to bogus or back. if (doesItemBlockRequiresRefresh(item)) { itemsToRefresh.push(item); } } } } for (const listHead of itemToListHead.values()) { itemsToRefresh.push(...collectListItemsToRefresh(listHead, changedItems)); } for (const item of new Set(itemsToRefresh)) { editing.reconvertItem(item); } }; function collectListItemsToRefresh(listHead, changedItems) { const itemsToRefresh = []; const visited = new Set(); const stack = []; for (const { node, previous } of iterateSiblingListBlocks(listHead, 'forward')) { if (visited.has(node)) { continue; } const itemIndent = node.getAttribute('listIndent'); // Current node is at the lower indent so trim the stack. if (previous && itemIndent < previous.getAttribute('listIndent')) { stack.length = itemIndent + 1; } // Update the stack for the current indent level. stack[itemIndent] = Object.fromEntries(Array.from(node.getAttributes()) .filter(([key]) => attributeNames.includes(key))); // Find all blocks of the current node. const blocks = getListItemBlocks(node, { direction: 'forward' }); for (const block of blocks) { visited.add(block); // Check if bogus vs plain paragraph needs refresh. if (doesItemBlockRequiresRefresh(block, blocks)) { itemsToRefresh.push(block); } // Check if wrapping with UL, OL, LIs needs refresh. else if (doesItemWrappingRequiresRefresh(block, stack, changedItems)) { itemsToRefresh.push(block); } } } return itemsToRefresh; } function doesItemBlockRequiresRefresh(item, blocks) { const viewElement = editing.mapper.toViewElement(item); if (!viewElement) { return false; } const needsRefresh = listEditing.fire('checkElement', { modelElement: item, viewElement }); if (needsRefresh) { return true; } if (!item.is('element', 'paragraph') && !item.is('element', 'listItem')) { return false; } const useBogus = shouldUseBogusParagraph(item, attributeNames, blocks); if (useBogus && viewElement.is('element', 'p')) { return true; } else if (!useBogus && viewElement.is('element', 'span')) { return true; } return false; } function doesItemWrappingRequiresRefresh(item, stack, changedItems) { // Items directly affected by some "change" don't need a refresh, they will be converted by their own changes. if (changedItems.has(item)) { return false; } const viewElement = editing.mapper.toViewElement(item); let indent = stack.length - 1; // Traverse down the stack to the root to verify if all ULs, OLs, and LIs are as expected. for (let element = viewElement.parent; !element.is('editableElement'); element = element.parent) { const isListItemElement = isListItemView(element); const isListElement = isListView(element); if (!isListElement && !isListItemElement) { continue; } const eventName = `checkAttributes:${isListItemElement ? 'item' : 'list'}`; const needsRefresh = listEditing.fire(eventName, { viewElement: element, modelAttributes: stack[indent] }); if (needsRefresh) { break; } if (isListElement) { indent--; // Don't need to iterate further if we already know that the item is wrapped appropriately. if (indent < 0) { return false; } } } return true; } } /** * Returns the list item downcast converter. * * @internal * @param attributeNames A list of attribute names that should be converted if they are set. * @param strategies The strategies. * @param model The model. */ export function listItemDowncastConverter(attributeNames, strategies, model, { dataPipeline } = {}) { const consumer = createAttributesConsumer(attributeNames); return (evt, data, conversionApi) => { const { writer, mapper, consumable } = conversionApi; const listItem = data.item; if (!attributeNames.includes(data.attributeKey)) { return; } // Test if attributes on the converted items are not consumed. if (!consumer(listItem, consumable)) { return; } // Use positions mapping instead of mapper.toViewElement( listItem ) to find outermost view element. // This is for cases when mapping is using inner view element like in the code blocks (pre > code). const viewElement = findMappedViewElement(listItem, mapper, model); // Remove custom item marker. removeCustomMarkerElements(viewElement, writer, mapper); // Unwrap element from current list wrappers. unwrapListItemBlock(viewElement, writer); // Insert custom item marker. const viewRange = insertCustomMarkerElements(listItem, viewElement, strategies, writer, { dataPipeline }); // Then wrap them with the new list wrappers (UL, OL, LI). wrapListItemBlock(listItem, viewRange, strategies, writer); }; } /** * The 'remove' downcast converter for custom markers. */ export function listItemDowncastRemoveConverter(schema) { return (evt, data, conversionApi) => { const { writer, mapper } = conversionApi; const elementName = evt.name.split(':')[1]; // Do not remove marker if the deleted element is some inline object inside paragraph. // See https://github.com/cksource/ckeditor5-internal/issues/3680. if (!schema.checkAttribute(elementName, 'listItemId')) { return; } // Find the view range start position by mapping the model position at which the remove happened. const viewStart = mapper.toViewPosition(data.position); const modelEnd = data.position.getShiftedBy(data.length); const viewEnd = mapper.toViewPosition(modelEnd, { isPhantom: true }); // Trim the range to remove in case some UI elements are on the view range boundaries. const viewRange = writer.createRange(viewStart, viewEnd).getTrimmed(); // Use positions mapping instead of mapper.toViewElement( listItem ) to find outermost view element. // This is for cases when mapping is using inner view element like in the code blocks (pre > code). const viewElement = viewRange.end.nodeBefore; /* istanbul ignore next -- @preserve */ if (!viewElement) { return; } // Remove custom item marker. removeCustomMarkerElements(viewElement, writer, mapper); }; } /** * Returns the bogus paragraph view element creator. A bogus paragraph is used if a list item contains only a single block or nested list. * * @internal * @param attributeNames The list of all model list attributes (including registered strategies). */ export function bogusParagraphCreator(attributeNames, { dataPipeline } = {}) { return (modelElement, { writer }) => { // Convert only if a bogus paragraph should be used. if (!shouldUseBogusParagraph(modelElement, attributeNames)) { return null; } if (!dataPipeline) { return writer.createContainerElement('span', { class: 'ck-list-bogus-paragraph' }); } // Using `<p>` in case there are some markers on it and transparentRendering will render it anyway. const viewElement = writer.createContainerElement('p'); writer.setCustomProperty('dataPipeline:transparentRendering', true, viewElement); return viewElement; }; } /** * Helper for mapping mode to view elements. It's using positions mapping instead of mapper.toViewElement( element ) * to find outermost view element. This is for cases when mapping is using inner view element like in the code blocks (pre > code). * * @internal * @param element The model element. * @param mapper The mapper instance. * @param model The model. */ export function findMappedViewElement(element, mapper, model) { const modelRange = model.createRangeOn(element); const viewRange = mapper.toViewRange(modelRange).getTrimmed(); return viewRange.end.nodeBefore; } /** * The model to view custom position mapping for cases when marker is injected at the beginning of a block. */ export function createModelToViewPositionMapper(strategies, view) { return (evt, data) => { if (data.modelPosition.offset > 0) { return; } const positionParent = data.modelPosition.parent; if (!isListItemBlock(positionParent)) { return; } if (!strategies.some(strategy => (strategy.scope == 'itemMarker' && strategy.canInjectMarkerIntoElement && strategy.canInjectMarkerIntoElement(positionParent)))) { return; } const viewElement = data.mapper.toViewElement(positionParent); const viewRange = view.createRangeIn(viewElement); const viewWalker = viewRange.getWalker(); let positionAfterLastMarker = viewRange.start; for (const { item } of viewWalker) { // Walk only over the non-mapped elements (UIElements, AttributeElements, $text, or any other element without mapping). if (item.is('element') && data.mapper.toModelElement(item) || item.is('$textProxy')) { break; } if (item.is('element') && item.getCustomProperty('listItemMarker')) { positionAfterLastMarker = view.createPositionAfter(item); // Jump over the content of the marker (this is not needed for UIElement but required for other element types). viewWalker.skip(({ previousPosition }) => !previousPosition.isEqual(positionAfterLastMarker)); } } data.viewPosition = positionAfterLastMarker; }; } /** * Removes a custom marker elements and item wrappers related to that marker. */ function removeCustomMarkerElements(viewElement, viewWriter, mapper) { // Remove item wrapper. while (viewElement.parent.is('attributeElement') && viewElement.parent.getCustomProperty('listItemWrapper')) { viewWriter.unwrap(viewWriter.createRangeOn(viewElement), viewElement.parent); } // Remove custom item markers. const markersToRemove = []; // Markers before a block. collectMarkersToRemove(viewWriter.createPositionBefore(viewElement).getWalker({ direction: 'backward' })); // Markers inside a block. collectMarkersToRemove(viewWriter.createRangeIn(viewElement).getWalker()); for (const marker of markersToRemove) { viewWriter.remove(marker); } function collectMarkersToRemove(viewWalker) { for (const { item } of viewWalker) { // Walk only over the non-mapped elements (UIElements, AttributeElements, $text, or any other element without mapping). if (item.is('element') && mapper.toModelElement(item)) { break; } if (item.is('element') && item.getCustomProperty('listItemMarker')) { markersToRemove.push(item); } } } } /** * Inserts a custom marker elements and wraps first block of a list item if marker requires it. */ function insertCustomMarkerElements(listItem, viewElement, strategies, writer, { dataPipeline }) { let viewRange = writer.createRangeOn(viewElement); // Marker can be inserted only before the first block of a list item. if (!isFirstBlockOfListItem(listItem)) { return viewRange; } for (const strategy of strategies) { if (strategy.scope != 'itemMarker') { continue; } // Create the custom marker element and inject it before the first block of the list item. const markerElement = strategy.createElement(writer, listItem, { dataPipeline }); if (!markerElement) { continue; } writer.setCustomProperty('listItemMarker', true, markerElement); if (strategy.canInjectMarkerIntoElement && strategy.canInjectMarkerIntoElement(listItem)) { writer.insert(writer.createPositionAt(viewElement, 0), markerElement); } else { writer.insert(viewRange.start, markerElement); viewRange = writer.createRange(writer.createPositionBefore(markerElement), writer.createPositionAfter(viewElement)); } // Wrap the marker and optionally the first block with an attribute element (label for to-do lists). if (!strategy.createWrapperElement || !strategy.canWrapElement) { continue; } const wrapper = strategy.createWrapperElement(writer, listItem, { dataPipeline }); writer.setCustomProperty('listItemWrapper', true, wrapper); // The whole block can be wrapped... if (strategy.canWrapElement(listItem)) { viewRange = writer.wrap(viewRange, wrapper); } else { // ... or only the marker element (if the block is downcasted to heading or block widget). viewRange = writer.wrap(writer.createRangeOn(markerElement), wrapper); viewRange = writer.createRange(viewRange.start, writer.createPositionAfter(viewElement)); } } return viewRange; } /** * Unwraps all ol, ul, and li attribute elements that are wrapping the provided view element. */ function unwrapListItemBlock(viewElement, viewWriter) { let attributeElement = viewElement.parent; while (attributeElement.is('attributeElement') && ['ul', 'ol', 'li'].includes(attributeElement.name)) { const parentElement = attributeElement.parent; viewWriter.unwrap(viewWriter.createRangeOn(viewElement), attributeElement); attributeElement = parentElement; } } /** * Wraps the given list item with appropriate attribute elements for ul, ol, and li. */ function wrapListItemBlock(listItem, viewRange, strategies, writer) { if (!listItem.hasAttribute('listIndent')) { return; } const listItemIndent = listItem.getAttribute('listIndent'); let currentListItem = listItem; for (let indent = listItemIndent; indent >= 0; indent--) { const listItemViewElement = createListItemElement(writer, indent, currentListItem.getAttribute('listItemId')); const listViewElement = createListElement(writer, indent, currentListItem.getAttribute('listType')); for (const strategy of strategies) { if ((strategy.scope == 'list' || strategy.scope == 'item') && currentListItem.hasAttribute(strategy.attributeName)) { strategy.setAttributeOnDowncast(writer, currentListItem.getAttribute(strategy.attributeName), strategy.scope == 'list' ? listViewElement : listItemViewElement); } } viewRange = writer.wrap(viewRange, listItemViewElement); viewRange = writer.wrap(viewRange, listViewElement); if (indent == 0) { break; } currentListItem = ListWalker.first(currentListItem, { lowerIndent: true }); // There is no list item with lower indent, this means this is a document fragment containing // only a part of nested list (like copy to clipboard) so we don't need to try to wrap it further. if (!currentListItem) { break; } } } // Returns the function that is responsible for consuming attributes that are set on the model node. function createAttributesConsumer(attributeNames) { return (node, consumable) => { const events = []; // Collect all set attributes that are triggering conversion. for (const attributeName of attributeNames) { if (node.hasAttribute(attributeName)) { events.push(`attribute:${attributeName}`); } } if (!events.every(event => consumable.test(node, event) !== false)) { return false; } events.forEach(event => consumable.consume(node, event)); return true; }; } // Whether the given item should be rendered as a bogus paragraph. function shouldUseBogusParagraph(item, attributeNames, blocks = getAllListItemBlocks(item)) { if (!isListItemBlock(item)) { return false; } for (const attributeKey of item.getAttributeKeys()) { // Ignore selection attributes stored on block elements. if (attributeKey.startsWith('selection:')) { continue; } // Don't use bogus paragraph if there are attributes from other features. if (!attributeNames.includes(attributeKey)) { return false; } } return blocks.length < 2; }