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