UNPKG

16.6 kBJavaScriptView Raw
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
10import { TreeWalker, getFillerOffset } from 'ckeditor5/src/engine';
11import { 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 */
19export 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 */
36export 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 */
62export 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 */
172export 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 */
197export 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 */
213export 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 */
246export 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 */
277export 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 */
296export 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 */
389export 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
413const 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.
417const 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 */
432export 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.
447function 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}