utils.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module list/utils
  7. */
  8. import { getFillerOffset } from '@ckeditor/ckeditor5-engine/src/view/containerelement';
  9. import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
  10. import TreeWalker from '@ckeditor/ckeditor5-engine/src/model/treewalker';
  11. /**
  12. * Creates a list item {@link module:engine/view/containerelement~ContainerElement}.
  13. *
  14. * @param {module:engine/view/downcastwriter~DowncastWriter} writer The writer instance.
  15. * @returns {module:engine/view/containerelement~ContainerElement}
  16. */
  17. export function createViewListItemElement( writer ) {
  18. const viewItem = writer.createContainerElement( 'li' );
  19. viewItem.getFillerOffset = getListItemFillerOffset;
  20. return viewItem;
  21. }
  22. /**
  23. * Helper function that creates a `<ul><li></li></ul>` or (`<ol>`) structure out of the given `modelItem` model `listItem` element.
  24. * Then, it binds the created view list item (`<li>`) with the model `listItem` element.
  25. * The function then returns the created view list item (`<li>`).
  26. *
  27. * @param {module:engine/model/item~Item} modelItem Model list item.
  28. * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface.
  29. * @returns {module:engine/view/containerelement~ContainerElement} View list element.
  30. */
  31. export function generateLiInUl( modelItem, conversionApi ) {
  32. const mapper = conversionApi.mapper;
  33. const viewWriter = conversionApi.writer;
  34. const listType = modelItem.getAttribute( 'listType' ) == 'numbered' ? 'ol' : 'ul';
  35. const viewItem = createViewListItemElement( viewWriter );
  36. const viewList = viewWriter.createContainerElement( listType, null );
  37. viewWriter.insert( viewWriter.createPositionAt( viewList, 0 ), viewItem );
  38. mapper.bindElements( modelItem, viewItem );
  39. return viewItem;
  40. }
  41. /**
  42. * Helper function that inserts a view list at a correct place and merges it with its siblings.
  43. * It takes a model list item element (`modelItem`) and a corresponding view list item element (`injectedItem`). The view list item
  44. * should be in a view list element (`<ul>` or `<ol>`) and should be its only child.
  45. * See comments below to better understand the algorithm.
  46. *
  47. * @param {module:engine/view/item~Item} modelItem Model list item.
  48. * @param {module:engine/view/containerelement~ContainerElement} injectedItem
  49. * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface.
  50. * @param {module:engine/model/model~Model} model The model instance.
  51. */
  52. export function injectViewList( modelItem, injectedItem, conversionApi, model ) {
  53. const injectedList = injectedItem.parent;
  54. const mapper = conversionApi.mapper;
  55. const viewWriter = conversionApi.writer;
  56. // The position where the view list will be inserted.
  57. let insertPosition = mapper.toViewPosition( model.createPositionBefore( modelItem ) );
  58. // 1. Find the previous list item that has the same or smaller indent. Basically we are looking for the first model item
  59. // that is a "parent" or "sibling" of the injected model item.
  60. // If there is no such list item, it means that the injected list item is the first item in "its list".
  61. const refItem = getSiblingListItem( modelItem.previousSibling, {
  62. sameIndent: true,
  63. smallerIndent: true,
  64. listIndent: modelItem.getAttribute( 'listIndent' )
  65. } );
  66. const prevItem = modelItem.previousSibling;
  67. if ( refItem && refItem.getAttribute( 'listIndent' ) == modelItem.getAttribute( 'listIndent' ) ) {
  68. // There is a list item with the same indent - we found the same-level sibling.
  69. // Break the list after it. The inserted view item will be added in the broken space.
  70. const viewItem = mapper.toViewElement( refItem );
  71. insertPosition = viewWriter.breakContainer( viewWriter.createPositionAfter( viewItem ) );
  72. } else {
  73. // There is no list item with the same indent. Check the previous model item.
  74. if ( prevItem && prevItem.name == 'listItem' ) {
  75. // If it is a list item, it has to have a lower indent.
  76. // It means that the inserted item should be added to it as its nested item.
  77. insertPosition = mapper.toViewPosition( model.createPositionAt( prevItem, 'end' ) );
  78. // There could be some not mapped elements (eg. span in to-do list) but we need to insert
  79. // a nested list directly inside the li element.
  80. const mappedViewAncestor = mapper.findMappedViewAncestor( insertPosition );
  81. const nestedList = findNestedList( mappedViewAncestor );
  82. // If there already is some nested list, then use it's position.
  83. if ( nestedList ) {
  84. insertPosition = viewWriter.createPositionBefore( nestedList );
  85. } else {
  86. // Else just put new list on the end of list item content.
  87. insertPosition = viewWriter.createPositionAt( mappedViewAncestor, 'end' );
  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. walker.position = breakPosition;
  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 && modelChild.getAttribute( 'listIndent' ) > modelItem.getAttribute( 'listIndent' ) ) {
  120. lastSubChild = child;
  121. } else {
  122. break;
  123. }
  124. }
  125. if ( lastSubChild ) {
  126. viewWriter.breakContainer( viewWriter.createPositionAfter( lastSubChild ) );
  127. viewWriter.move( viewWriter.createRangeOn( lastSubChild.parent ), viewWriter.createPositionAt( injectedItem, 'end' ) );
  128. }
  129. }
  130. }
  131. // Merge the inserted view list with its possible neighbor lists.
  132. mergeViewLists( viewWriter, injectedList, injectedList.nextSibling );
  133. mergeViewLists( viewWriter, injectedList.previousSibling, injectedList );
  134. }
  135. /**
  136. * Helper function that takes two parameters that are expected to be view list elements, and merges them.
  137. * The merge happens only if both parameters are list elements of the same type (the same element name and the same class attributes).
  138. *
  139. * @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter The writer instance.
  140. * @param {module:engine/view/item~Item} firstList The first element to compare.
  141. * @param {module:engine/view/item~Item} secondList The second element to compare.
  142. * @returns {module:engine/view/position~Position|null} The position after merge or `null` when there was no merge.
  143. */
  144. export function mergeViewLists( viewWriter, firstList, secondList ) {
  145. // Check if two lists are going to be merged.
  146. if ( !firstList || !secondList || ( firstList.name != 'ul' && firstList.name != 'ol' ) ) {
  147. return null;
  148. }
  149. // Both parameters are list elements, so compare types now.
  150. if ( firstList.name != secondList.name || firstList.getAttribute( 'class' ) !== secondList.getAttribute( 'class' ) ) {
  151. return null;
  152. }
  153. return viewWriter.mergeContainers( viewWriter.createPositionAfter( firstList ) );
  154. }
  155. /**
  156. * Helper function that for a given `view.Position`, returns a `view.Position` that is after all `view.UIElement`s that
  157. * are after the given position.
  158. *
  159. * For example:
  160. * `<container:p>foo^<ui:span></ui:span><ui:span></ui:span>bar</container:p>`
  161. * For position ^, the position before "bar" will be returned.
  162. *
  163. * @param {module:engine/view/position~Position} viewPosition
  164. * @returns {module:engine/view/position~Position}
  165. */
  166. export function positionAfterUiElements( viewPosition ) {
  167. return viewPosition.getLastMatchingPosition( value => value.item.is( 'uiElement' ) );
  168. }
  169. /**
  170. * Helper function that searches for a previous list item sibling of a given model item that meets the given criteria
  171. * passed by the options object.
  172. *
  173. * @param {module:engine/model/item~Item} modelItem
  174. * @param {Object} options Search criteria.
  175. * @param {Boolean} [options.sameIndent=false] Whether the sought sibling should have the same indentation.
  176. * @param {Boolean} [options.smallerIndent=false] Whether the sought sibling should have a smaller indentation.
  177. * @param {Number} [options.listIndent] The reference indentation.
  178. * @param {'forward'|'backward'} [options.direction='backward'] Walking direction.
  179. * @returns {module:engine/model/item~Item|null}
  180. */
  181. export function getSiblingListItem( modelItem, options ) {
  182. const sameIndent = !!options.sameIndent;
  183. const smallerIndent = !!options.smallerIndent;
  184. const indent = options.listIndent;
  185. let item = modelItem;
  186. while ( item && item.name == 'listItem' ) {
  187. const itemIndent = item.getAttribute( 'listIndent' );
  188. if ( ( sameIndent && indent == itemIndent ) || ( smallerIndent && indent > itemIndent ) ) {
  189. return item;
  190. }
  191. if ( options.direction === 'forward' ) {
  192. item = item.nextSibling;
  193. } else {
  194. item = item.previousSibling;
  195. }
  196. }
  197. return null;
  198. }
  199. /**
  200. * Helper method for creating a UI button and linking it with an appropriate command.
  201. *
  202. * @private
  203. * @param {module:core/editor/editor~Editor} editor The editor instance to which the UI component will be added.
  204. * @param {String} commandName The name of the command.
  205. * @param {Object} label The button label.
  206. * @param {String} icon The source of the icon.
  207. */
  208. export function createUIComponent( editor, commandName, label, icon ) {
  209. editor.ui.componentFactory.add( commandName, locale => {
  210. const command = editor.commands.get( commandName );
  211. const buttonView = new ButtonView( locale );
  212. buttonView.set( {
  213. label,
  214. icon,
  215. tooltip: true,
  216. isToggleable: true
  217. } );
  218. // Bind button model to command.
  219. buttonView.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' );
  220. // Execute command.
  221. buttonView.on( 'execute', () => {
  222. editor.execute( commandName );
  223. editor.editing.view.focus();
  224. } );
  225. return buttonView;
  226. } );
  227. }
  228. /**
  229. * Returns a first list view element that is direct child of the given view element.
  230. *
  231. * @param {module:engine/view/element~Element} viewElement
  232. * @return {module:engine/view/element~Element|null}
  233. */
  234. export function findNestedList( viewElement ) {
  235. for ( const node of viewElement.getChildren() ) {
  236. if ( node.name == 'ul' || node.name == 'ol' ) {
  237. return node;
  238. }
  239. }
  240. return null;
  241. }
  242. /**
  243. * Returns an array with all `listItem` elements that represents the same list.
  244. *
  245. * It means that values for `listIndent`, `listType`, and `listStyle` for all items are equal.
  246. *
  247. * @param {module:engine/model/position~Position} position Starting position.
  248. * @param {'forward'|'backward'} direction Walking direction.
  249. * @returns {Array.<module:engine/model/element~Element>}
  250. */
  251. export function getSiblingNodes( position, direction ) {
  252. const items = [];
  253. const listItem = position.parent;
  254. const walkerOptions = {
  255. ignoreElementEnd: true,
  256. startPosition: position,
  257. shallow: true,
  258. direction
  259. };
  260. const limitIndent = listItem.getAttribute( 'listIndent' );
  261. const nodes = [ ...new TreeWalker( walkerOptions ) ]
  262. .filter( value => value.item.is( 'element' ) )
  263. .map( value => value.item );
  264. for ( const element of nodes ) {
  265. // If found something else than `listItem`, we're out of the list scope.
  266. if ( !element.is( 'element', 'listItem' ) ) {
  267. break;
  268. }
  269. // If current parsed item has lower indent that element that the element that was a starting point,
  270. // it means we left a nested list. Abort searching items.
  271. //
  272. // ■ List item 1. [listIndent=0]
  273. // ○ List item 2.[] [listIndent=1], limitIndent = 1,
  274. // ○ List item 3. [listIndent=1]
  275. // ■ List item 4. [listIndent=0]
  276. //
  277. // Abort searching when leave nested list.
  278. if ( element.getAttribute( 'listIndent' ) < limitIndent ) {
  279. break;
  280. }
  281. // ■ List item 1.[] [listIndent=0] limitIndent = 0,
  282. // ○ List item 2. [listIndent=1]
  283. // ○ List item 3. [listIndent=1]
  284. // ■ List item 4. [listIndent=0]
  285. //
  286. // Ignore nested lists.
  287. if ( element.getAttribute( 'listIndent' ) > limitIndent ) {
  288. continue;
  289. }
  290. // ■ List item 1.[] [listType=bulleted]
  291. // 1. List item 2. [listType=numbered]
  292. // 2.List item 3. [listType=numbered]
  293. //
  294. // Abort searching when found a different kind of a list.
  295. if ( element.getAttribute( 'listType' ) !== listItem.getAttribute( 'listType' ) ) {
  296. break;
  297. }
  298. // ■ List item 1.[] [listType=bulleted]
  299. // ■ List item 2. [listType=bulleted]
  300. // ○ List item 3. [listType=bulleted]
  301. // ○ List item 4. [listType=bulleted]
  302. //
  303. // Abort searching when found a different list style.
  304. if ( element.getAttribute( 'listStyle' ) !== listItem.getAttribute( 'listStyle' ) ) {
  305. break;
  306. }
  307. if ( direction === 'backward' ) {
  308. items.unshift( element );
  309. } else {
  310. items.push( element );
  311. }
  312. }
  313. return items;
  314. }
  315. // Implementation of getFillerOffset for view list item element.
  316. //
  317. // @returns {Number|null} Block filler offset or `null` if block filler is not needed.
  318. function getListItemFillerOffset() {
  319. const hasOnlyLists = !this.isEmpty && ( this.getChild( 0 ).name == 'ul' || this.getChild( 0 ).name == 'ol' );
  320. if ( this.isEmpty || hasOnlyLists ) {
  321. return 0;
  322. }
  323. return getFillerOffset.call( this );
  324. }