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 | */
|
8 | import { 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 | */
|
14 | export 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 | */
|
28 | export 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 | */
|
49 | export 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 | }
|
139 | export 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 | */
|
159 | export 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 | */
|
172 | export 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 | */
|
194 | export 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 | */
|
212 | export 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 | */
|
294 | export 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 | }
|
313 | const 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.
|
316 | const 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 | */
|
327 | export 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 | */
|
341 | function 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 | }
|