UNPKG

15 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2024, 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/legacylist/legacyutils
7 */
8import { TreeWalker, getFillerOffset } from 'ckeditor5/src/engine.js';
9/**
10 * Creates a list item {@link module:engine/view/containerelement~ContainerElement}.
11 *
12 * @param writer The writer instance.
13 */
14export function createViewListItemElement(writer) {
15 const viewItem = writer.createContainerElement('li');
16 viewItem.getFillerOffset = getListItemFillerOffset;
17 return viewItem;
18}
19/**
20 * Helper function that creates a `<ul><li></li></ul>` or (`<ol>`) structure out of the given `modelItem` model `listItem` element.
21 * Then, it binds the created view list item (`<li>`) with the model `listItem` element.
22 * The function then returns the created view list item (`<li>`).
23 *
24 * @param modelItem Model list item.
25 * @param conversionApi Conversion interface.
26 * @returns View list element.
27 */
28export function generateLiInUl(modelItem, conversionApi) {
29 const mapper = conversionApi.mapper;
30 const viewWriter = conversionApi.writer;
31 const listType = modelItem.getAttribute('listType') == 'numbered' ? 'ol' : 'ul';
32 const viewItem = createViewListItemElement(viewWriter);
33 const viewList = viewWriter.createContainerElement(listType, null);
34 viewWriter.insert(viewWriter.createPositionAt(viewList, 0), viewItem);
35 mapper.bindElements(modelItem, viewItem);
36 return viewItem;
37}
38/**
39 * Helper function that inserts a view list at a correct place and merges it with its siblings.
40 * It takes a model list item element (`modelItem`) and a corresponding view list item element (`injectedItem`). The view list item
41 * should be in a view list element (`<ul>` or `<ol>`) and should be its only child.
42 * See comments below to better understand the algorithm.
43 *
44 * @param modelItem Model list item.
45 * @param injectedItem
46 * @param conversionApi Conversion interface.
47 * @param model The model instance.
48 */
49export function injectViewList(modelItem, injectedItem, conversionApi, model) {
50 const injectedList = injectedItem.parent;
51 const mapper = conversionApi.mapper;
52 const viewWriter = conversionApi.writer;
53 // The position where the view list will be inserted.
54 let insertPosition = mapper.toViewPosition(model.createPositionBefore(modelItem));
55 // 1. Find the previous list item that has the same or smaller indent. Basically we are looking for the first model item
56 // that is a "parent" or "sibling" of the injected model item.
57 // If there is no such list item, it means that the injected list item is the first item in "its list".
58 const refItem = getSiblingListItem(modelItem.previousSibling, {
59 sameIndent: true,
60 smallerIndent: true,
61 listIndent: modelItem.getAttribute('listIndent')
62 });
63 const prevItem = modelItem.previousSibling;
64 if (refItem && refItem.getAttribute('listIndent') == modelItem.getAttribute('listIndent')) {
65 // There is a list item with the same indent - we found the same-level sibling.
66 // Break the list after it. The inserted view item will be added in the broken space.
67 const viewItem = mapper.toViewElement(refItem);
68 insertPosition = viewWriter.breakContainer(viewWriter.createPositionAfter(viewItem));
69 }
70 else {
71 // There is no list item with the same indent. Check the previous model item.
72 if (prevItem && prevItem.name == 'listItem') {
73 // If it is a list item, it has to have a lower indent.
74 // It means that the inserted item should be added to it as its nested item.
75 insertPosition = mapper.toViewPosition(model.createPositionAt(prevItem, 'end'));
76 // There could be some not mapped elements (eg. span in to-do list) but we need to insert
77 // a nested list directly inside the li element.
78 const mappedViewAncestor = mapper.findMappedViewAncestor(insertPosition);
79 const nestedList = findNestedList(mappedViewAncestor);
80 // If there already is some nested list, then use it's position.
81 if (nestedList) {
82 insertPosition = viewWriter.createPositionBefore(nestedList);
83 }
84 else {
85 // Else just put new list on the end of list item content.
86 insertPosition = viewWriter.createPositionAt(mappedViewAncestor, 'end');
87 }
88 }
89 else {
90 // The previous item is not a list item (or does not exist at all).
91 // Just map the position and insert the view item at the mapped position.
92 insertPosition = mapper.toViewPosition(model.createPositionBefore(modelItem));
93 }
94 }
95 insertPosition = positionAfterUiElements(insertPosition);
96 // Insert the view item.
97 viewWriter.insert(insertPosition, injectedList);
98 // 2. Handle possible children of the injected model item.
99 if (prevItem && prevItem.name == 'listItem') {
100 const prevView = mapper.toViewElement(prevItem);
101 const walkerBoundaries = viewWriter.createRange(viewWriter.createPositionAt(prevView, 0), insertPosition);
102 const walker = walkerBoundaries.getWalker({ ignoreElementEnd: true });
103 for (const value of walker) {
104 if (value.item.is('element', 'li')) {
105 const breakPosition = viewWriter.breakContainer(viewWriter.createPositionBefore(value.item));
106 const viewList = value.item.parent;
107 const targetPosition = viewWriter.createPositionAt(injectedItem, 'end');
108 mergeViewLists(viewWriter, targetPosition.nodeBefore, targetPosition.nodeAfter);
109 viewWriter.move(viewWriter.createRangeOn(viewList), targetPosition);
110 // This is bad, but those lists will be removed soon anyway.
111 walker._position = breakPosition;
112 }
113 }
114 }
115 else {
116 const nextViewList = injectedList.nextSibling;
117 if (nextViewList && (nextViewList.is('element', 'ul') || nextViewList.is('element', 'ol'))) {
118 let lastSubChild = null;
119 for (const child of nextViewList.getChildren()) {
120 const modelChild = mapper.toModelElement(child);
121 if (modelChild &&
122 modelChild.getAttribute('listIndent') > modelItem.getAttribute('listIndent')) {
123 lastSubChild = child;
124 }
125 else {
126 break;
127 }
128 }
129 if (lastSubChild) {
130 viewWriter.breakContainer(viewWriter.createPositionAfter(lastSubChild));
131 viewWriter.move(viewWriter.createRangeOn(lastSubChild.parent), viewWriter.createPositionAt(injectedItem, 'end'));
132 }
133 }
134 }
135 // Merge the inserted view list with its possible neighbor lists.
136 mergeViewLists(viewWriter, injectedList, injectedList.nextSibling);
137 mergeViewLists(viewWriter, injectedList.previousSibling, injectedList);
138}
139export function mergeViewLists(viewWriter, firstList, secondList) {
140 // Check if two lists are going to be merged.
141 if (!firstList || !secondList || (firstList.name != 'ul' && firstList.name != 'ol')) {
142 return null;
143 }
144 // Both parameters are list elements, so compare types now.
145 if (firstList.name != secondList.name || firstList.getAttribute('class') !== secondList.getAttribute('class')) {
146 return null;
147 }
148 return viewWriter.mergeContainers(viewWriter.createPositionAfter(firstList));
149}
150/**
151 * Helper function that for a given `view.Position`, returns a `view.Position` that is after all `view.UIElement`s that
152 * are after the given position.
153 *
154 * For example:
155 * `<container:p>foo^<ui:span></ui:span><ui:span></ui:span>bar</container:p>`
156 * For position ^, the position before "bar" will be returned.
157 *
158 */
159export function positionAfterUiElements(viewPosition) {
160 return viewPosition.getLastMatchingPosition(value => value.item.is('uiElement'));
161}
162/**
163 * Helper function that searches for a previous list item sibling of a given model item that meets the given criteria
164 * passed by the options object.
165 *
166 * @param options Search criteria.
167 * @param options.sameIndent Whether the sought sibling should have the same indentation.
168 * @param options.smallerIndent Whether the sought sibling should have a smaller indentation.
169 * @param options.listIndent The reference indentation.
170 * @param options.direction Walking direction.
171 */
172export function getSiblingListItem(modelItem, options) {
173 const sameIndent = !!options.sameIndent;
174 const smallerIndent = !!options.smallerIndent;
175 const indent = options.listIndent;
176 let item = modelItem;
177 while (item && item.name == 'listItem') {
178 const itemIndent = item.getAttribute('listIndent');
179 if ((sameIndent && indent == itemIndent) || (smallerIndent && indent > itemIndent)) {
180 return item;
181 }
182 if (options.direction === 'forward') {
183 item = item.nextSibling;
184 }
185 else {
186 item = item.previousSibling;
187 }
188 }
189 return null;
190}
191/**
192 * Returns a first list view element that is direct child of the given view element.
193 */
194export function findNestedList(viewElement) {
195 for (const node of viewElement.getChildren()) {
196 if (node.name == 'ul' || node.name == 'ol') {
197 return node;
198 }
199 }
200 return null;
201}
202/**
203 * Returns an array with all `listItem` elements that represent the same list.
204 *
205 * It means that values of `listIndent`, `listType`, `listStyle`, `listReversed` and `listStart` for all items are equal.
206 *
207 * Additionally, if the `position` is inside a list item, that list item will be returned as well.
208 *
209 * @param position Starting position.
210 * @param direction Walking direction.
211 */
212export function getSiblingNodes(position, direction) {
213 const items = [];
214 const listItem = position.parent;
215 const walkerOptions = {
216 ignoreElementEnd: false,
217 startPosition: position,
218 shallow: true,
219 direction
220 };
221 const limitIndent = listItem.getAttribute('listIndent');
222 const nodes = [...new TreeWalker(walkerOptions)]
223 .filter(value => value.item.is('element'))
224 .map(value => value.item);
225 for (const element of nodes) {
226 // If found something else than `listItem`, we're out of the list scope.
227 if (!element.is('element', 'listItem')) {
228 break;
229 }
230 // If current parsed item has lower indent that element that the element that was a starting point,
231 // it means we left a nested list. Abort searching items.
232 //
233 // ■ List item 1. [listIndent=0]
234 // ○ List item 2.[] [listIndent=1], limitIndent = 1,
235 // ○ List item 3. [listIndent=1]
236 // ■ List item 4. [listIndent=0]
237 //
238 // Abort searching when leave nested list.
239 if (element.getAttribute('listIndent') < limitIndent) {
240 break;
241 }
242 // ■ List item 1.[] [listIndent=0] limitIndent = 0,
243 // ○ List item 2. [listIndent=1]
244 // ○ List item 3. [listIndent=1]
245 // ■ List item 4. [listIndent=0]
246 //
247 // Ignore nested lists.
248 if (element.getAttribute('listIndent') > limitIndent) {
249 continue;
250 }
251 // ■ List item 1.[] [listType=bulleted]
252 // 1. List item 2. [listType=numbered]
253 // 2.List item 3. [listType=numbered]
254 //
255 // Abort searching when found a different kind of a list.
256 if (element.getAttribute('listType') !== listItem.getAttribute('listType')) {
257 break;
258 }
259 // ■ List item 1.[] [listType=bulleted]
260 // ■ List item 2. [listType=bulleted]
261 // ○ List item 3. [listType=bulleted]
262 // ○ List item 4. [listType=bulleted]
263 //
264 // Abort searching when found a different list style,
265 if (element.getAttribute('listStyle') !== listItem.getAttribute('listStyle')) {
266 break;
267 }
268 // ... different direction
269 if (element.getAttribute('listReversed') !== listItem.getAttribute('listReversed')) {
270 break;
271 }
272 // ... and different start index
273 if (element.getAttribute('listStart') !== listItem.getAttribute('listStart')) {
274 break;
275 }
276 if (direction === 'backward') {
277 items.unshift(element);
278 }
279 else {
280 items.push(element);
281 }
282 }
283 return items;
284}
285/**
286 * Returns an array with all `listItem` elements in the model selection.
287 *
288 * It returns all the items even if only a part of the list is selected, including items that belong to nested lists.
289 * If no list is selected, it returns an empty array.
290 * The order of the elements is not specified.
291 *
292 * @internal
293 */
294export function getSelectedListItems(model) {
295 const document = model.document;
296 // For all selected blocks find all list items that are being selected
297 // and update the `listStyle` attribute in those lists.
298 let listItems = [...document.selection.getSelectedBlocks()]
299 .filter(element => element.is('element', 'listItem'))
300 .map(element => {
301 const position = model.change(writer => writer.createPositionAt(element, 0));
302 return [
303 ...getSiblingNodes(position, 'backward'),
304 ...getSiblingNodes(position, 'forward')
305 ];
306 })
307 .flat();
308 // Since `getSelectedBlocks()` can return items that belong to the same list, and
309 // `getSiblingNodes()` returns the entire list, we need to remove duplicated items.
310 listItems = [...new Set(listItems)];
311 return listItems;
312}
313const BULLETED_LIST_STYLE_TYPES = ['disc', 'circle', 'square'];
314// There's a lot of them (https://www.w3.org/TR/css-counter-styles-3/#typedef-counter-style).
315// Let's support only those that can be selected by ListPropertiesUI.
316const NUMBERED_LIST_STYLE_TYPES = [
317 'decimal',
318 'decimal-leading-zero',
319 'lower-roman',
320 'upper-roman',
321 'lower-latin',
322 'upper-latin'
323];
324/**
325 * Checks whether the given list-style-type is supported by numbered or bulleted list.
326 */
327export function getListTypeFromListStyleType(listStyleType) {
328 if (BULLETED_LIST_STYLE_TYPES.includes(listStyleType)) {
329 return 'bulleted';
330 }
331 if (NUMBERED_LIST_STYLE_TYPES.includes(listStyleType)) {
332 return 'numbered';
333 }
334 return null;
335}
336/**
337 * Implementation of getFillerOffset for view list item element.
338 *
339 * @returns Block filler offset or `null` if block filler is not needed.
340 */
341function getListItemFillerOffset() {
342 const hasOnlyLists = !this.isEmpty && (this.getChild(0).name == 'ul' || this.getChild(0).name == 'ol');
343 if (this.isEmpty || hasOnlyLists) {
344 return 0;
345 }
346 return getFillerOffset.call(this);
347}