UNPKG

@ckeditor/ckeditor5-list

Version:

Ordered and unordered lists feature to CKEditor 5.

348 lines (347 loc) 15 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/legacylist/legacyutils */ import { TreeWalker, getFillerOffset } from 'ckeditor5/src/engine.js'; /** * Creates a list item {@link module:engine/view/containerelement~ContainerElement}. * * @param writer The writer instance. */ export function createViewListItemElement(writer) { const viewItem = writer.createContainerElement('li'); viewItem.getFillerOffset = getListItemFillerOffset; return viewItem; } /** * Helper function that creates a `<ul><li></li></ul>` or (`<ol>`) structure out of the given `modelItem` model `listItem` element. * Then, it binds the created view list item (`<li>`) with the model `listItem` element. * The function then returns the created view list item (`<li>`). * * @param modelItem Model list item. * @param conversionApi Conversion interface. * @returns View list element. */ export function generateLiInUl(modelItem, conversionApi) { const mapper = conversionApi.mapper; const viewWriter = conversionApi.writer; const listType = modelItem.getAttribute('listType') == 'numbered' ? 'ol' : 'ul'; const viewItem = createViewListItemElement(viewWriter); const viewList = viewWriter.createContainerElement(listType, null); viewWriter.insert(viewWriter.createPositionAt(viewList, 0), viewItem); mapper.bindElements(modelItem, viewItem); return viewItem; } /** * Helper function that inserts a view list at a correct place and merges it with its siblings. * It takes a model list item element (`modelItem`) and a corresponding view list item element (`injectedItem`). The view list item * should be in a view list element (`<ul>` or `<ol>`) and should be its only child. * See comments below to better understand the algorithm. * * @param modelItem Model list item. * @param injectedItem * @param conversionApi Conversion interface. * @param model The model instance. */ export function injectViewList(modelItem, injectedItem, conversionApi, model) { const injectedList = injectedItem.parent; const mapper = conversionApi.mapper; const viewWriter = conversionApi.writer; // The position where the view list will be inserted. let insertPosition = mapper.toViewPosition(model.createPositionBefore(modelItem)); // 1. Find the previous list item that has the same or smaller indent. Basically we are looking for the first model item // that is a "parent" or "sibling" of the injected model item. // If there is no such list item, it means that the injected list item is the first item in "its list". const refItem = getSiblingListItem(modelItem.previousSibling, { sameIndent: true, smallerIndent: true, listIndent: modelItem.getAttribute('listIndent') }); const prevItem = modelItem.previousSibling; if (refItem && refItem.getAttribute('listIndent') == modelItem.getAttribute('listIndent')) { // There is a list item with the same indent - we found the same-level sibling. // Break the list after it. The inserted view item will be added in the broken space. const viewItem = mapper.toViewElement(refItem); insertPosition = viewWriter.breakContainer(viewWriter.createPositionAfter(viewItem)); } else { // There is no list item with the same indent. Check the previous model item. if (prevItem && prevItem.name == 'listItem') { // If it is a list item, it has to have a lower indent. // It means that the inserted item should be added to it as its nested item. insertPosition = mapper.toViewPosition(model.createPositionAt(prevItem, 'end')); // There could be some not mapped elements (eg. span in to-do list) but we need to insert // a nested list directly inside the li element. const mappedViewAncestor = mapper.findMappedViewAncestor(insertPosition); const nestedList = findNestedList(mappedViewAncestor); // If there already is some nested list, then use it's position. if (nestedList) { insertPosition = viewWriter.createPositionBefore(nestedList); } else { // Else just put new list on the end of list item content. insertPosition = viewWriter.createPositionAt(mappedViewAncestor, 'end'); } } else { // The previous item is not a list item (or does not exist at all). // Just map the position and insert the view item at the mapped position. insertPosition = mapper.toViewPosition(model.createPositionBefore(modelItem)); } } insertPosition = positionAfterUiElements(insertPosition); // Insert the view item. viewWriter.insert(insertPosition, injectedList); // 2. Handle possible children of the injected model item. if (prevItem && prevItem.name == 'listItem') { const prevView = mapper.toViewElement(prevItem); const walkerBoundaries = viewWriter.createRange(viewWriter.createPositionAt(prevView, 0), insertPosition); const walker = walkerBoundaries.getWalker({ ignoreElementEnd: true }); for (const value of walker) { if (value.item.is('element', 'li')) { const breakPosition = viewWriter.breakContainer(viewWriter.createPositionBefore(value.item)); const viewList = value.item.parent; const targetPosition = viewWriter.createPositionAt(injectedItem, 'end'); mergeViewLists(viewWriter, targetPosition.nodeBefore, targetPosition.nodeAfter); viewWriter.move(viewWriter.createRangeOn(viewList), targetPosition); // This is bad, but those lists will be removed soon anyway. walker._position = breakPosition; } } } else { const nextViewList = injectedList.nextSibling; if (nextViewList && (nextViewList.is('element', 'ul') || nextViewList.is('element', 'ol'))) { let lastSubChild = null; for (const child of nextViewList.getChildren()) { const modelChild = mapper.toModelElement(child); if (modelChild && modelChild.getAttribute('listIndent') > modelItem.getAttribute('listIndent')) { lastSubChild = child; } else { break; } } if (lastSubChild) { viewWriter.breakContainer(viewWriter.createPositionAfter(lastSubChild)); viewWriter.move(viewWriter.createRangeOn(lastSubChild.parent), viewWriter.createPositionAt(injectedItem, 'end')); } } } // Merge the inserted view list with its possible neighbor lists. mergeViewLists(viewWriter, injectedList, injectedList.nextSibling); mergeViewLists(viewWriter, injectedList.previousSibling, injectedList); } export function mergeViewLists(viewWriter, firstList, secondList) { // Check if two lists are going to be merged. if (!firstList || !secondList || (firstList.name != 'ul' && firstList.name != 'ol')) { return null; } // Both parameters are list elements, so compare types now. if (firstList.name != secondList.name || firstList.getAttribute('class') !== secondList.getAttribute('class')) { return null; } return viewWriter.mergeContainers(viewWriter.createPositionAfter(firstList)); } /** * Helper function that for a given `view.Position`, returns a `view.Position` that is after all `view.UIElement`s that * are after the given position. * * For example: * `<container:p>foo^<ui:span></ui:span><ui:span></ui:span>bar</container:p>` * For position ^, the position before "bar" will be returned. * */ export function positionAfterUiElements(viewPosition) { return viewPosition.getLastMatchingPosition(value => value.item.is('uiElement')); } /** * Helper function that searches for a previous list item sibling of a given model item that meets the given criteria * passed by the options object. * * @param options Search criteria. * @param options.sameIndent Whether the sought sibling should have the same indentation. * @param options.smallerIndent Whether the sought sibling should have a smaller indentation. * @param options.listIndent The reference indentation. * @param options.direction Walking direction. */ export function getSiblingListItem(modelItem, options) { const sameIndent = !!options.sameIndent; const smallerIndent = !!options.smallerIndent; const indent = options.listIndent; let item = modelItem; while (item && item.name == 'listItem') { const itemIndent = item.getAttribute('listIndent'); if ((sameIndent && indent == itemIndent) || (smallerIndent && indent > itemIndent)) { return item; } if (options.direction === 'forward') { item = item.nextSibling; } else { item = item.previousSibling; } } return null; } /** * Returns a first list view element that is direct child of the given view element. */ export function findNestedList(viewElement) { for (const node of viewElement.getChildren()) { if (node.name == 'ul' || node.name == 'ol') { return node; } } return null; } /** * Returns an array with all `listItem` elements that represent the same list. * * It means that values of `listIndent`, `listType`, `listStyle`, `listReversed` and `listStart` for all items are equal. * * Additionally, if the `position` is inside a list item, that list item will be returned as well. * * @param position Starting position. * @param direction Walking direction. */ export function getSiblingNodes(position, direction) { const items = []; const listItem = position.parent; const walkerOptions = { ignoreElementEnd: false, startPosition: position, shallow: true, direction }; const limitIndent = listItem.getAttribute('listIndent'); const nodes = [...new TreeWalker(walkerOptions)] .filter(value => value.item.is('element')) .map(value => value.item); for (const element of nodes) { // If found something else than `listItem`, we're out of the list scope. if (!element.is('element', 'listItem')) { break; } // If current parsed item has lower indent that element that the element that was a starting point, // it means we left a nested list. Abort searching items. // // ■ List item 1. [listIndent=0] // ○ List item 2.[] [listIndent=1], limitIndent = 1, // ○ List item 3. [listIndent=1] // ■ List item 4. [listIndent=0] // // Abort searching when leave nested list. if (element.getAttribute('listIndent') < limitIndent) { break; } // ■ List item 1.[] [listIndent=0] limitIndent = 0, // ○ List item 2. [listIndent=1] // ○ List item 3. [listIndent=1] // ■ List item 4. [listIndent=0] // // Ignore nested lists. if (element.getAttribute('listIndent') > limitIndent) { continue; } // ■ List item 1.[] [listType=bulleted] // 1. List item 2. [listType=numbered] // 2.List item 3. [listType=numbered] // // Abort searching when found a different kind of a list. if (element.getAttribute('listType') !== listItem.getAttribute('listType')) { break; } // ■ List item 1.[] [listType=bulleted] // ■ List item 2. [listType=bulleted] // ○ List item 3. [listType=bulleted] // ○ List item 4. [listType=bulleted] // // Abort searching when found a different list style, if (element.getAttribute('listStyle') !== listItem.getAttribute('listStyle')) { break; } // ... different direction if (element.getAttribute('listReversed') !== listItem.getAttribute('listReversed')) { break; } // ... and different start index if (element.getAttribute('listStart') !== listItem.getAttribute('listStart')) { break; } if (direction === 'backward') { items.unshift(element); } else { items.push(element); } } return items; } /** * Returns an array with all `listItem` elements in the model selection. * * It returns all the items even if only a part of the list is selected, including items that belong to nested lists. * If no list is selected, it returns an empty array. * The order of the elements is not specified. * * @internal */ export function getSelectedListItems(model) { const document = model.document; // For all selected blocks find all list items that are being selected // and update the `listStyle` attribute in those lists. let listItems = [...document.selection.getSelectedBlocks()] .filter(element => element.is('element', 'listItem')) .map(element => { const position = model.change(writer => writer.createPositionAt(element, 0)); return [ ...getSiblingNodes(position, 'backward'), ...getSiblingNodes(position, 'forward') ]; }) .flat(); // Since `getSelectedBlocks()` can return items that belong to the same list, and // `getSiblingNodes()` returns the entire list, we need to remove duplicated items. listItems = [...new Set(listItems)]; return listItems; } const BULLETED_LIST_STYLE_TYPES = ['disc', 'circle', 'square']; // There's a lot of them (https://www.w3.org/TR/css-counter-styles-3/#typedef-counter-style). // Let's support only those that can be selected by ListPropertiesUI. const NUMBERED_LIST_STYLE_TYPES = [ 'decimal', 'decimal-leading-zero', 'lower-roman', 'upper-roman', 'lower-latin', 'upper-latin' ]; /** * Checks whether the given list-style-type is supported by numbered or bulleted list. */ export function getListTypeFromListStyleType(listStyleType) { if (BULLETED_LIST_STYLE_TYPES.includes(listStyleType)) { return 'bulleted'; } if (NUMBERED_LIST_STYLE_TYPES.includes(listStyleType)) { return 'numbered'; } return null; } /** * Implementation of getFillerOffset for view list item element. * * @returns Block filler offset or `null` if block filler is not needed. */ function getListItemFillerOffset() { const hasOnlyLists = !this.isEmpty && (this.getChild(0).name == 'ul' || this.getChild(0).name == 'ol'); if (this.isEmpty || hasOnlyLists) { return 0; } return getFillerOffset.call(this); }