UNPKG

15.6 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
3 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4 */
5/**
6 * @module list/documentlist/converters
7 */
8import { UpcastWriter } from 'ckeditor5/src/engine';
9import { getAllListItemBlocks, getListItemBlocks, isListItemBlock, ListItemUid } from './utils/model';
10import { createListElement, createListItemElement, getIndent, isListView, isListItemView } from './utils/view';
11import ListWalker, { iterateSiblingListBlocks } from './utils/listwalker';
12import { findAndAddListHeadToMap } from './utils/postfixers';
13/**
14 * Returns the upcast converter for list items. It's supposed to work after the block converters (content inside list items) are converted.
15 *
16 * @internal
17 */
18export function listItemUpcastConverter() {
19 return (evt, data, conversionApi) => {
20 const { writer, schema } = conversionApi;
21 if (!data.modelRange) {
22 return;
23 }
24 const items = Array.from(data.modelRange.getItems({ shallow: true }))
25 .filter((item) => schema.checkAttribute(item, 'listItemId'));
26 if (!items.length) {
27 return;
28 }
29 const attributes = {
30 listItemId: ListItemUid.next(),
31 listIndent: getIndent(data.viewItem),
32 listType: data.viewItem.parent && data.viewItem.parent.is('element', 'ol') ? 'numbered' : 'bulleted'
33 };
34 for (const item of items) {
35 // Set list attributes only on same level items, those nested deeper are already handled by the recursive conversion.
36 if (!isListItemBlock(item)) {
37 writer.setAttributes(attributes, item);
38 }
39 }
40 if (items.length > 1) {
41 // Make sure that list item that contain only nested list will preserve paragraph for itself:
42 // <ul>
43 // <li>
44 // <p></p> <-- this one must be kept
45 // <ul>
46 // <li></li>
47 // </ul>
48 // </li>
49 // </ul>
50 if (items[1].getAttribute('listItemId') != attributes.listItemId) {
51 conversionApi.keepEmptyElement(items[0]);
52 }
53 }
54 };
55}
56/**
57 * Returns the upcast converter for the `<ul>` and `<ol>` view elements that cleans the input view of garbage.
58 * This is mostly to clean whitespaces from between the `<li>` view elements inside the view list element. However,
59 * incorrect data can also be cleared if the view was incorrect.
60 *
61 * @internal
62 */
63export function listUpcastCleanList() {
64 return (evt, data, conversionApi) => {
65 if (!conversionApi.consumable.test(data.viewItem, { name: true })) {
66 return;
67 }
68 const viewWriter = new UpcastWriter(data.viewItem.document);
69 for (const child of Array.from(data.viewItem.getChildren())) {
70 if (!isListItemView(child) && !isListView(child)) {
71 viewWriter.remove(child);
72 }
73 }
74 };
75}
76/**
77 * Returns a model document change:data event listener that triggers conversion of related items if needed.
78 *
79 * @internal
80 * @param model The editor model.
81 * @param editing The editing controller.
82 * @param attributeNames The list of all model list attributes (including registered strategies).
83 * @param documentListEditing The document list editing plugin.
84 */
85export function reconvertItemsOnDataChange(model, editing, attributeNames, documentListEditing) {
86 return () => {
87 const changes = model.document.differ.getChanges();
88 const itemsToRefresh = [];
89 const itemToListHead = new Map();
90 const changedItems = new Set();
91 for (const entry of changes) {
92 if (entry.type == 'insert' && entry.name != '$text') {
93 findAndAddListHeadToMap(entry.position, itemToListHead);
94 // Insert of a non-list item.
95 if (!entry.attributes.has('listItemId')) {
96 findAndAddListHeadToMap(entry.position.getShiftedBy(entry.length), itemToListHead);
97 }
98 else {
99 changedItems.add(entry.position.nodeAfter);
100 }
101 }
102 // Removed list item.
103 else if (entry.type == 'remove' && entry.attributes.has('listItemId')) {
104 findAndAddListHeadToMap(entry.position, itemToListHead);
105 }
106 // Changed list attribute.
107 else if (entry.type == 'attribute') {
108 const item = entry.range.start.nodeAfter;
109 if (attributeNames.includes(entry.attributeKey)) {
110 findAndAddListHeadToMap(entry.range.start, itemToListHead);
111 if (entry.attributeNewValue === null) {
112 findAndAddListHeadToMap(entry.range.start.getShiftedBy(1), itemToListHead);
113 // Check if paragraph should be converted from bogus to plain paragraph.
114 if (doesItemParagraphRequiresRefresh(item)) {
115 itemsToRefresh.push(item);
116 }
117 }
118 else {
119 changedItems.add(item);
120 }
121 }
122 else if (isListItemBlock(item)) {
123 // Some other attribute was changed on the list item,
124 // check if paragraph does not need to be converted to bogus or back.
125 if (doesItemParagraphRequiresRefresh(item)) {
126 itemsToRefresh.push(item);
127 }
128 }
129 }
130 }
131 for (const listHead of itemToListHead.values()) {
132 itemsToRefresh.push(...collectListItemsToRefresh(listHead, changedItems));
133 }
134 for (const item of new Set(itemsToRefresh)) {
135 editing.reconvertItem(item);
136 }
137 };
138 function collectListItemsToRefresh(listHead, changedItems) {
139 const itemsToRefresh = [];
140 const visited = new Set();
141 const stack = [];
142 for (const { node, previous } of iterateSiblingListBlocks(listHead, 'forward')) {
143 if (visited.has(node)) {
144 continue;
145 }
146 const itemIndent = node.getAttribute('listIndent');
147 // Current node is at the lower indent so trim the stack.
148 if (previous && itemIndent < previous.getAttribute('listIndent')) {
149 stack.length = itemIndent + 1;
150 }
151 // Update the stack for the current indent level.
152 stack[itemIndent] = Object.fromEntries(Array.from(node.getAttributes())
153 .filter(([key]) => attributeNames.includes(key)));
154 // Find all blocks of the current node.
155 const blocks = getListItemBlocks(node, { direction: 'forward' });
156 for (const block of blocks) {
157 visited.add(block);
158 // Check if bogus vs plain paragraph needs refresh.
159 if (doesItemParagraphRequiresRefresh(block, blocks)) {
160 itemsToRefresh.push(block);
161 }
162 // Check if wrapping with UL, OL, LIs needs refresh.
163 else if (doesItemWrappingRequiresRefresh(block, stack, changedItems)) {
164 itemsToRefresh.push(block);
165 }
166 }
167 }
168 return itemsToRefresh;
169 }
170 function doesItemParagraphRequiresRefresh(item, blocks) {
171 if (!item.is('element', 'paragraph')) {
172 return false;
173 }
174 const viewElement = editing.mapper.toViewElement(item);
175 if (!viewElement) {
176 return false;
177 }
178 const useBogus = shouldUseBogusParagraph(item, attributeNames, blocks);
179 if (useBogus && viewElement.is('element', 'p')) {
180 return true;
181 }
182 else if (!useBogus && viewElement.is('element', 'span')) {
183 return true;
184 }
185 return false;
186 }
187 function doesItemWrappingRequiresRefresh(item, stack, changedItems) {
188 // Items directly affected by some "change" don't need a refresh, they will be converted by their own changes.
189 if (changedItems.has(item)) {
190 return false;
191 }
192 const viewElement = editing.mapper.toViewElement(item);
193 let indent = stack.length - 1;
194 // Traverse down the stack to the root to verify if all ULs, OLs, and LIs are as expected.
195 for (let element = viewElement.parent; !element.is('editableElement'); element = element.parent) {
196 const isListItemElement = isListItemView(element);
197 const isListElement = isListView(element);
198 if (!isListElement && !isListItemElement) {
199 continue;
200 }
201 const eventName = `checkAttributes:${isListItemElement ? 'item' : 'list'}`;
202 const needsRefresh = documentListEditing.fire(eventName, {
203 viewElement: element,
204 modelAttributes: stack[indent]
205 });
206 if (needsRefresh) {
207 break;
208 }
209 if (isListElement) {
210 indent--;
211 // Don't need to iterate further if we already know that the item is wrapped appropriately.
212 if (indent < 0) {
213 return false;
214 }
215 }
216 }
217 return true;
218 }
219}
220/**
221 * Returns the list item downcast converter.
222 *
223 * @internal
224 * @param attributeNames A list of attribute names that should be converted if they are set.
225 * @param strategies The strategies.
226 * @param model The model.
227 */
228export function listItemDowncastConverter(attributeNames, strategies, model) {
229 const consumer = createAttributesConsumer(attributeNames);
230 return (evt, data, conversionApi) => {
231 const { writer, mapper, consumable } = conversionApi;
232 const listItem = data.item;
233 if (!attributeNames.includes(data.attributeKey)) {
234 return;
235 }
236 // Test if attributes on the converted items are not consumed.
237 if (!consumer(listItem, consumable)) {
238 return;
239 }
240 // Use positions mapping instead of mapper.toViewElement( listItem ) to find outermost view element.
241 // This is for cases when mapping is using inner view element like in the code blocks (pre > code).
242 const viewElement = findMappedViewElement(listItem, mapper, model);
243 // Unwrap element from current list wrappers.
244 unwrapListItemBlock(viewElement, writer);
245 // Then wrap them with the new list wrappers.
246 wrapListItemBlock(listItem, writer.createRangeOn(viewElement), strategies, writer);
247 };
248}
249/**
250 * Returns the bogus paragraph view element creator. A bogus paragraph is used if a list item contains only a single block or nested list.
251 *
252 * @internal
253 * @param attributeNames The list of all model list attributes (including registered strategies).
254 */
255export function bogusParagraphCreator(attributeNames, { dataPipeline } = {}) {
256 return (modelElement, { writer }) => {
257 // Convert only if a bogus paragraph should be used.
258 if (!shouldUseBogusParagraph(modelElement, attributeNames)) {
259 return null;
260 }
261 if (!dataPipeline) {
262 return writer.createContainerElement('span', { class: 'ck-list-bogus-paragraph' });
263 }
264 // Using `<p>` in case there are some markers on it and transparentRendering will render it anyway.
265 const viewElement = writer.createContainerElement('p');
266 writer.setCustomProperty('dataPipeline:transparentRendering', true, viewElement);
267 return viewElement;
268 };
269}
270/**
271 * Helper for mapping mode to view elements. It's using positions mapping instead of mapper.toViewElement( element )
272 * to find outermost view element. This is for cases when mapping is using inner view element like in the code blocks (pre > code).
273 *
274 * @internal
275 * @param element The model element.
276 * @param mapper The mapper instance.
277 * @param model The model.
278 */
279export function findMappedViewElement(element, mapper, model) {
280 const modelRange = model.createRangeOn(element);
281 const viewRange = mapper.toViewRange(modelRange).getTrimmed();
282 return viewRange.getContainedElement();
283}
284// Unwraps all ol, ul, and li attribute elements that are wrapping the provided view element.
285function unwrapListItemBlock(viewElement, viewWriter) {
286 let attributeElement = viewElement.parent;
287 while (attributeElement.is('attributeElement') && ['ul', 'ol', 'li'].includes(attributeElement.name)) {
288 const parentElement = attributeElement.parent;
289 viewWriter.unwrap(viewWriter.createRangeOn(viewElement), attributeElement);
290 attributeElement = parentElement;
291 }
292}
293// Wraps the given list item with appropriate attribute elements for ul, ol, and li.
294function wrapListItemBlock(listItem, viewRange, strategies, writer) {
295 if (!listItem.hasAttribute('listIndent')) {
296 return;
297 }
298 const listItemIndent = listItem.getAttribute('listIndent');
299 let currentListItem = listItem;
300 for (let indent = listItemIndent; indent >= 0; indent--) {
301 const listItemViewElement = createListItemElement(writer, indent, currentListItem.getAttribute('listItemId'));
302 const listViewElement = createListElement(writer, indent, currentListItem.getAttribute('listType'));
303 for (const strategy of strategies) {
304 if (currentListItem.hasAttribute(strategy.attributeName)) {
305 strategy.setAttributeOnDowncast(writer, currentListItem.getAttribute(strategy.attributeName), strategy.scope == 'list' ? listViewElement : listItemViewElement);
306 }
307 }
308 viewRange = writer.wrap(viewRange, listItemViewElement);
309 viewRange = writer.wrap(viewRange, listViewElement);
310 if (indent == 0) {
311 break;
312 }
313 currentListItem = ListWalker.first(currentListItem, { lowerIndent: true });
314 // There is no list item with lower indent, this means this is a document fragment containing
315 // only a part of nested list (like copy to clipboard) so we don't need to try to wrap it further.
316 if (!currentListItem) {
317 break;
318 }
319 }
320}
321// Returns the function that is responsible for consuming attributes that are set on the model node.
322function createAttributesConsumer(attributeNames) {
323 return (node, consumable) => {
324 const events = [];
325 // Collect all set attributes that are triggering conversion.
326 for (const attributeName of attributeNames) {
327 if (node.hasAttribute(attributeName)) {
328 events.push(`attribute:${attributeName}`);
329 }
330 }
331 if (!events.every(event => consumable.test(node, event) !== false)) {
332 return false;
333 }
334 events.forEach(event => consumable.consume(node, event));
335 return true;
336 };
337}
338// Whether the given item should be rendered as a bogus paragraph.
339function shouldUseBogusParagraph(item, attributeNames, blocks = getAllListItemBlocks(item)) {
340 if (!isListItemBlock(item)) {
341 return false;
342 }
343 for (const attributeKey of item.getAttributeKeys()) {
344 // Ignore selection attributes stored on block elements.
345 if (attributeKey.startsWith('selection:')) {
346 continue;
347 }
348 // Don't use bogus paragraph if there are attributes from other features.
349 if (!attributeNames.includes(attributeKey)) {
350 return false;
351 }
352 }
353 return blocks.length < 2;
354}