@ckeditor/ckeditor5-list
Version:
Ordered and unordered lists feature to CKEditor 5.
516 lines (515 loc) • 23.1 kB
JavaScript
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module list/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;
}