UNPKG

16 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 */
5import { TreeWalker, getFillerOffset } from 'ckeditor5/src/engine';
6import { 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 */
12export 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 */
26export 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 */
47export 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}
137export 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 */
157export 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 */
170export 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 */
198export 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 */
221export 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 */
239export 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 */
321export 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}
340const 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.
343const 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 */
354export 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 */
368function 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}