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