converters.js 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055
  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/converters
  7. */
  8. import {
  9. generateLiInUl,
  10. injectViewList,
  11. mergeViewLists,
  12. getSiblingListItem,
  13. positionAfterUiElements
  14. } from './utils';
  15. import TreeWalker from '@ckeditor/ckeditor5-engine/src/model/treewalker';
  16. /**
  17. * A model-to-view converter for the `listItem` model element insertion.
  18. *
  19. * It creates a `<ul><li></li><ul>` (or `<ol>`) view structure out of a `listItem` model element, inserts it at the correct
  20. * position, and merges the list with surrounding lists (if available).
  21. *
  22. * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert
  23. * @param {module:engine/model/model~Model} model Model instance.
  24. * @returns {Function} Returns a conversion callback.
  25. */
  26. export function modelViewInsertion( model ) {
  27. return ( evt, data, conversionApi ) => {
  28. const consumable = conversionApi.consumable;
  29. if ( !consumable.test( data.item, 'insert' ) ||
  30. !consumable.test( data.item, 'attribute:listType' ) ||
  31. !consumable.test( data.item, 'attribute:listIndent' )
  32. ) {
  33. return;
  34. }
  35. consumable.consume( data.item, 'insert' );
  36. consumable.consume( data.item, 'attribute:listType' );
  37. consumable.consume( data.item, 'attribute:listIndent' );
  38. const modelItem = data.item;
  39. const viewItem = generateLiInUl( modelItem, conversionApi );
  40. injectViewList( modelItem, viewItem, conversionApi, model );
  41. };
  42. }
  43. /**
  44. * A model-to-view converter for the `listItem` model element removal.
  45. *
  46. * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:remove
  47. * @param {module:engine/model/model~Model} model Model instance.
  48. * @returns {Function} Returns a conversion callback.
  49. */
  50. export function modelViewRemove( model ) {
  51. return ( evt, data, conversionApi ) => {
  52. const viewPosition = conversionApi.mapper.toViewPosition( data.position );
  53. const viewStart = viewPosition.getLastMatchingPosition( value => !value.item.is( 'element', 'li' ) );
  54. const viewItem = viewStart.nodeAfter;
  55. const viewWriter = conversionApi.writer;
  56. // 1. Break the container after and before the list item.
  57. // This will create a view list with one view list item - the one to remove.
  58. viewWriter.breakContainer( viewWriter.createPositionBefore( viewItem ) );
  59. viewWriter.breakContainer( viewWriter.createPositionAfter( viewItem ) );
  60. // 2. Remove the list with the item to remove.
  61. const viewList = viewItem.parent;
  62. const viewListPrev = viewList.previousSibling;
  63. const removeRange = viewWriter.createRangeOn( viewList );
  64. const removed = viewWriter.remove( removeRange );
  65. // 3. Merge the whole created by breaking and removing the list.
  66. if ( viewListPrev && viewListPrev.nextSibling ) {
  67. mergeViewLists( viewWriter, viewListPrev, viewListPrev.nextSibling );
  68. }
  69. // 4. Bring back nested list that was in the removed <li>.
  70. const modelItem = conversionApi.mapper.toModelElement( viewItem );
  71. hoistNestedLists( modelItem.getAttribute( 'listIndent' ) + 1, data.position, removeRange.start, viewItem, conversionApi, model );
  72. // 5. Unbind removed view item and all children.
  73. for ( const child of viewWriter.createRangeIn( removed ).getItems() ) {
  74. conversionApi.mapper.unbindViewElement( child );
  75. }
  76. evt.stop();
  77. };
  78. }
  79. /**
  80. * A model-to-view converter for the `type` attribute change on the `listItem` model element.
  81. *
  82. * This change means that the `<li>` element parent changes from `<ul>` to `<ol>` (or vice versa). This is accomplished
  83. * by breaking view elements and changing their name. The next {@link module:list/converters~modelViewMergeAfterChangeType}
  84. * converter will attempt to merge split nodes.
  85. *
  86. * Splitting this conversion into 2 steps makes it possible to add an additional conversion in the middle.
  87. * Check {@link module:list/todolistconverters~modelViewChangeType} to see an example of it.
  88. *
  89. * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
  90. * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
  91. * @param {Object} data Additional information about the change.
  92. * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface.
  93. */
  94. export function modelViewChangeType( evt, data, conversionApi ) {
  95. if ( !conversionApi.consumable.consume( data.item, 'attribute:listType' ) ) {
  96. return;
  97. }
  98. const viewItem = conversionApi.mapper.toViewElement( data.item );
  99. const viewWriter = conversionApi.writer;
  100. // Break the container after and before the list item.
  101. // This will create a view list with one view list item -- the one that changed type.
  102. viewWriter.breakContainer( viewWriter.createPositionBefore( viewItem ) );
  103. viewWriter.breakContainer( viewWriter.createPositionAfter( viewItem ) );
  104. // Change name of the view list that holds the changed view item.
  105. // We cannot just change name property, because that would not render properly.
  106. const viewList = viewItem.parent;
  107. const listName = data.attributeNewValue == 'numbered' ? 'ol' : 'ul';
  108. viewWriter.rename( listName, viewList );
  109. }
  110. /**
  111. * A model-to-view converter that attempts to merge nodes split by {@link module:list/converters~modelViewChangeType}.
  112. *
  113. * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
  114. * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
  115. * @param {Object} data Additional information about the change.
  116. * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface.
  117. */
  118. export function modelViewMergeAfterChangeType( evt, data, conversionApi ) {
  119. const viewItem = conversionApi.mapper.toViewElement( data.item );
  120. const viewList = viewItem.parent;
  121. const viewWriter = conversionApi.writer;
  122. // Merge the changed view list with other lists, if possible.
  123. mergeViewLists( viewWriter, viewList, viewList.nextSibling );
  124. mergeViewLists( viewWriter, viewList.previousSibling, viewList );
  125. // Consumable insertion of children inside the item. They are already handled by re-building the item in view.
  126. for ( const child of data.item.getChildren() ) {
  127. conversionApi.consumable.consume( child, 'insert' );
  128. }
  129. }
  130. /**
  131. * A model-to-view converter for the `listIndent` attribute change on the `listItem` model element.
  132. *
  133. * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
  134. * @param {module:engine/model/model~Model} model Model instance.
  135. * @returns {Function} Returns a conversion callback.
  136. */
  137. export function modelViewChangeIndent( model ) {
  138. return ( evt, data, conversionApi ) => {
  139. if ( !conversionApi.consumable.consume( data.item, 'attribute:listIndent' ) ) {
  140. return;
  141. }
  142. const viewItem = conversionApi.mapper.toViewElement( data.item );
  143. const viewWriter = conversionApi.writer;
  144. // 1. Break the container after and before the list item.
  145. // This will create a view list with one view list item -- the one that changed type.
  146. viewWriter.breakContainer( viewWriter.createPositionBefore( viewItem ) );
  147. viewWriter.breakContainer( viewWriter.createPositionAfter( viewItem ) );
  148. // 2. Extract view list with changed view list item and merge "hole" possibly created by breaking and removing elements.
  149. const viewList = viewItem.parent;
  150. const viewListPrev = viewList.previousSibling;
  151. const removeRange = viewWriter.createRangeOn( viewList );
  152. viewWriter.remove( removeRange );
  153. if ( viewListPrev && viewListPrev.nextSibling ) {
  154. mergeViewLists( viewWriter, viewListPrev, viewListPrev.nextSibling );
  155. }
  156. // 3. Bring back nested list that was in the removed <li>.
  157. hoistNestedLists( data.attributeOldValue + 1, data.range.start, removeRange.start, viewItem, conversionApi, model );
  158. // 4. Inject view list like it is newly inserted.
  159. injectViewList( data.item, viewItem, conversionApi, model );
  160. // 5. Consume insertion of children inside the item. They are already handled by re-building the item in view.
  161. for ( const child of data.item.getChildren() ) {
  162. conversionApi.consumable.consume( child, 'insert' );
  163. }
  164. };
  165. }
  166. /**
  167. * A special model-to-view converter introduced by the {@link module:list/list~List list feature}. This converter is fired for
  168. * insert change of every model item, and should be fired before the actual converter. The converter checks whether the inserted
  169. * model item is a non-`listItem` element. If it is, and it is inserted inside a view list, the converter breaks the
  170. * list so the model element is inserted to the view parent element corresponding to its model parent element.
  171. *
  172. * The converter prevents such situations:
  173. *
  174. * // Model: // View:
  175. * <listItem>foo</listItem> <ul>
  176. * <listItem>bar</listItem> <li>foo</li>
  177. * <li>bar</li>
  178. * </ul>
  179. *
  180. * // After change: // Correct view guaranteed by this converter:
  181. * <listItem>foo</listItem> <ul><li>foo</li></ul><p>xxx</p><ul><li>bar</li></ul>
  182. * <paragraph>xxx</paragraph> // Instead of this wrong view state:
  183. * <listItem>bar</listItem> <ul><li>foo</li><p>xxx</p><li>bar</li></ul>
  184. *
  185. * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert
  186. * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
  187. * @param {Object} data Additional information about the change.
  188. * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface.
  189. */
  190. export function modelViewSplitOnInsert( evt, data, conversionApi ) {
  191. if ( data.item.name != 'listItem' ) {
  192. let viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
  193. const viewWriter = conversionApi.writer;
  194. const lists = [];
  195. // Break multiple ULs/OLs if there are.
  196. //
  197. // Imagine following list:
  198. //
  199. // 1 --------
  200. // 1.1 --------
  201. // 1.1.1 --------
  202. // 1.1.2 --------
  203. // 1.1.3 --------
  204. // 1.1.3.1 --------
  205. // 1.2 --------
  206. // 1.2.1 --------
  207. // 2 --------
  208. //
  209. // Insert paragraph after item 1.1.1:
  210. //
  211. // 1 --------
  212. // 1.1 --------
  213. // 1.1.1 --------
  214. //
  215. // Lorem ipsum.
  216. //
  217. // 1.1.2 --------
  218. // 1.1.3 --------
  219. // 1.1.3.1 --------
  220. // 1.2 --------
  221. // 1.2.1 --------
  222. // 2 --------
  223. //
  224. // In this case 1.1.2 has to become beginning of a new list.
  225. // We need to break list before 1.1.2 (obvious), then we need to break list also before 1.2.
  226. // Then we need to move those broken pieces one after another and merge:
  227. //
  228. // 1 --------
  229. // 1.1 --------
  230. // 1.1.1 --------
  231. //
  232. // Lorem ipsum.
  233. //
  234. // 1.1.2 --------
  235. // 1.1.3 --------
  236. // 1.1.3.1 --------
  237. // 1.2 --------
  238. // 1.2.1 --------
  239. // 2 --------
  240. //
  241. while ( viewPosition.parent.name == 'ul' || viewPosition.parent.name == 'ol' ) {
  242. viewPosition = viewWriter.breakContainer( viewPosition );
  243. if ( viewPosition.parent.name != 'li' ) {
  244. break;
  245. }
  246. // Remove lists that are after inserted element.
  247. // They will be brought back later, below the inserted element.
  248. const removeStart = viewPosition;
  249. const removeEnd = viewWriter.createPositionAt( viewPosition.parent, 'end' );
  250. // Don't remove if there is nothing to remove.
  251. if ( !removeStart.isEqual( removeEnd ) ) {
  252. const removed = viewWriter.remove( viewWriter.createRange( removeStart, removeEnd ) );
  253. lists.push( removed );
  254. }
  255. viewPosition = viewWriter.createPositionAfter( viewPosition.parent );
  256. }
  257. // Bring back removed lists.
  258. if ( lists.length > 0 ) {
  259. for ( let i = 0; i < lists.length; i++ ) {
  260. const previousList = viewPosition.nodeBefore;
  261. const insertedRange = viewWriter.insert( viewPosition, lists[ i ] );
  262. viewPosition = insertedRange.end;
  263. // Don't merge first list! We want a split in that place (this is why this converter is introduced).
  264. if ( i > 0 ) {
  265. const mergePos = mergeViewLists( viewWriter, previousList, previousList.nextSibling );
  266. // If `mergePos` is in `previousList` it means that the lists got merged.
  267. // In this case, we need to fix insert position.
  268. if ( mergePos && mergePos.parent == previousList ) {
  269. viewPosition.offset--;
  270. }
  271. }
  272. }
  273. // Merge last inserted list with element after it.
  274. mergeViewLists( viewWriter, viewPosition.nodeBefore, viewPosition.nodeAfter );
  275. }
  276. }
  277. }
  278. /**
  279. * A special model-to-view converter introduced by the {@link module:list/list~List list feature}. This converter takes care of
  280. * merging view lists after something is removed or moved from near them.
  281. *
  282. * Example:
  283. *
  284. * // Model: // View:
  285. * <listItem>foo</listItem> <ul><li>foo</li></ul>
  286. * <paragraph>xxx</paragraph> <p>xxx</p>
  287. * <listItem>bar</listItem> <ul><li>bar</li></ul>
  288. *
  289. * // After change: // Correct view guaranteed by this converter:
  290. * <listItem>foo</listItem> <ul>
  291. * <listItem>bar</listItem> <li>foo</li>
  292. * <li>bar</li>
  293. * </ul>
  294. *
  295. * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:remove
  296. * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
  297. * @param {Object} data Additional information about the change.
  298. * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface.
  299. */
  300. export function modelViewMergeAfter( evt, data, conversionApi ) {
  301. const viewPosition = conversionApi.mapper.toViewPosition( data.position );
  302. const viewItemPrev = viewPosition.nodeBefore;
  303. const viewItemNext = viewPosition.nodeAfter;
  304. // Merge lists if something (remove, move) was done from inside of list.
  305. // Merging will be done only if both items are view lists of the same type.
  306. // The check is done inside the helper function.
  307. mergeViewLists( conversionApi.writer, viewItemPrev, viewItemNext );
  308. }
  309. /**
  310. * A view-to-model converter that converts the `<li>` view elements into the `listItem` model elements.
  311. *
  312. * To set correct values of the `listType` and `listIndent` attributes the converter:
  313. * * checks `<li>`'s parent,
  314. * * stores and increases the `conversionApi.store.indent` value when `<li>`'s sub-items are converted.
  315. *
  316. * @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
  317. * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
  318. * @param {Object} data An object containing conversion input and a placeholder for conversion output and possibly other values.
  319. * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface to be used by the callback.
  320. */
  321. export function viewModelConverter( evt, data, conversionApi ) {
  322. if ( conversionApi.consumable.consume( data.viewItem, { name: true } ) ) {
  323. const writer = conversionApi.writer;
  324. // 1. Create `listItem` model element.
  325. const listItem = writer.createElement( 'listItem' );
  326. // 2. Handle `listItem` model element attributes.
  327. const indent = getIndent( data.viewItem );
  328. writer.setAttribute( 'listIndent', indent, listItem );
  329. // Set 'bulleted' as default. If this item is pasted into a context,
  330. const type = data.viewItem.parent && data.viewItem.parent.name == 'ol' ? 'numbered' : 'bulleted';
  331. writer.setAttribute( 'listType', type, listItem );
  332. if ( !conversionApi.safeInsert( listItem, data.modelCursor ) ) {
  333. return;
  334. }
  335. const nextPosition = viewToModelListItemChildrenConverter( listItem, data.viewItem.getChildren(), conversionApi );
  336. // Result range starts before the first item and ends after the last.
  337. data.modelRange = writer.createRange( data.modelCursor, nextPosition );
  338. conversionApi.updateConversionResult( listItem, data );
  339. }
  340. }
  341. /**
  342. * A view-to-model converter for the `<ul>` and `<ol>` view elements that cleans the input view of garbage.
  343. * This is mostly to clean whitespaces from between the `<li>` view elements inside the view list element, however, also
  344. * incorrect data can be cleared if the view was incorrect.
  345. *
  346. * @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
  347. * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
  348. * @param {Object} data An object containing conversion input and a placeholder for conversion output and possibly other values.
  349. * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface to be used by the callback.
  350. */
  351. export function cleanList( evt, data, conversionApi ) {
  352. if ( conversionApi.consumable.test( data.viewItem, { name: true } ) ) {
  353. // Caching children because when we start removing them iterating fails.
  354. const children = Array.from( data.viewItem.getChildren() );
  355. for ( const child of children ) {
  356. const isWrongElement = !( child.is( 'element', 'li' ) || isList( child ) );
  357. if ( isWrongElement ) {
  358. child._remove();
  359. }
  360. }
  361. }
  362. }
  363. /**
  364. * A view-to-model converter for the `<li>` elements that cleans whitespace formatting from the input view.
  365. *
  366. * @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
  367. * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
  368. * @param {Object} data An object containing conversion input and a placeholder for conversion output and possibly other values.
  369. * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface to be used by the callback.
  370. */
  371. export function cleanListItem( evt, data, conversionApi ) {
  372. if ( conversionApi.consumable.test( data.viewItem, { name: true } ) ) {
  373. if ( data.viewItem.childCount === 0 ) {
  374. return;
  375. }
  376. const children = [ ...data.viewItem.getChildren() ];
  377. let foundList = false;
  378. let firstNode = true;
  379. for ( const child of children ) {
  380. if ( foundList && !isList( child ) ) {
  381. child._remove();
  382. }
  383. if ( child.is( '$text' ) ) {
  384. // If this is the first node and it's a text node, left-trim it.
  385. if ( firstNode ) {
  386. child._data = child.data.replace( /^\s+/, '' );
  387. }
  388. // If this is the last text node before <ul> or <ol>, right-trim it.
  389. if ( !child.nextSibling || isList( child.nextSibling ) ) {
  390. child._data = child.data.replace( /\s+$/, '' );
  391. }
  392. } else if ( isList( child ) ) {
  393. // If this is a <ul> or <ol>, do not process it, just mark that we already visited list element.
  394. foundList = true;
  395. }
  396. firstNode = false;
  397. }
  398. }
  399. }
  400. /**
  401. * Returns a callback for model position to view position mapping for {@link module:engine/conversion/mapper~Mapper}. The callback fixes
  402. * positions between the `listItem` elements that would be incorrectly mapped because of how list items are represented in the model
  403. * and in the view.
  404. *
  405. * @see module:engine/conversion/mapper~Mapper#event:modelToViewPosition
  406. * @param {module:engine/view/view~View} view A view instance.
  407. * @returns {Function}
  408. */
  409. export function modelToViewPosition( view ) {
  410. return ( evt, data ) => {
  411. if ( data.isPhantom ) {
  412. return;
  413. }
  414. const modelItem = data.modelPosition.nodeBefore;
  415. if ( modelItem && modelItem.is( 'element', 'listItem' ) ) {
  416. const viewItem = data.mapper.toViewElement( modelItem );
  417. const topmostViewList = viewItem.getAncestors().find( isList );
  418. const walker = view.createPositionAt( viewItem, 0 ).getWalker();
  419. for ( const value of walker ) {
  420. if ( value.type == 'elementStart' && value.item.is( 'element', 'li' ) ) {
  421. data.viewPosition = value.previousPosition;
  422. break;
  423. } else if ( value.type == 'elementEnd' && value.item == topmostViewList ) {
  424. data.viewPosition = value.nextPosition;
  425. break;
  426. }
  427. }
  428. }
  429. };
  430. }
  431. /**
  432. * The callback for view position to model position mapping for {@link module:engine/conversion/mapper~Mapper}. The callback fixes
  433. * positions between the `<li>` elements that would be incorrectly mapped because of how list items are represented in the model
  434. * and in the view.
  435. *
  436. * @see module:engine/conversion/mapper~Mapper#event:viewToModelPosition
  437. * @param {module:engine/model/model~Model} model Model instance.
  438. * @returns {Function} Returns a conversion callback.
  439. */
  440. export function viewToModelPosition( model ) {
  441. return ( evt, data ) => {
  442. const viewPos = data.viewPosition;
  443. const viewParent = viewPos.parent;
  444. const mapper = data.mapper;
  445. if ( viewParent.name == 'ul' || viewParent.name == 'ol' ) {
  446. // Position is directly in <ul> or <ol>.
  447. if ( !viewPos.isAtEnd ) {
  448. // If position is not at the end, it must be before <li>.
  449. // Get that <li>, map it to `listItem` and set model position before that `listItem`.
  450. const modelNode = mapper.toModelElement( viewPos.nodeAfter );
  451. data.modelPosition = model.createPositionBefore( modelNode );
  452. } else {
  453. // Position is at the end of <ul> or <ol>, so there is no <li> after it to be mapped.
  454. // There is <li> before the position, but we cannot just map it to `listItem` and set model position after it,
  455. // because that <li> may contain nested items.
  456. // We will check "model length" of that <li>, in other words - how many `listItem`s are in that <li>.
  457. const modelNode = mapper.toModelElement( viewPos.nodeBefore );
  458. const modelLength = mapper.getModelLength( viewPos.nodeBefore );
  459. // Then we get model position before mapped `listItem` and shift it accordingly.
  460. data.modelPosition = model.createPositionBefore( modelNode ).getShiftedBy( modelLength );
  461. }
  462. evt.stop();
  463. } else if (
  464. viewParent.name == 'li' &&
  465. viewPos.nodeBefore &&
  466. ( viewPos.nodeBefore.name == 'ul' || viewPos.nodeBefore.name == 'ol' )
  467. ) {
  468. // In most cases when view position is in <li> it is in text and this is a correct position.
  469. // However, if position is after <ul> or <ol> we have to fix it -- because in model <ul>/<ol> are not in the `listItem`.
  470. const modelNode = mapper.toModelElement( viewParent );
  471. // Check all <ul>s and <ol>s that are in the <li> but before mapped position.
  472. // Get model length of those elements and then add it to the offset of `listItem` mapped to the original <li>.
  473. let modelLength = 1; // Starts from 1 because the original <li> has to be counted in too.
  474. let viewList = viewPos.nodeBefore;
  475. while ( viewList && isList( viewList ) ) {
  476. modelLength += mapper.getModelLength( viewList );
  477. viewList = viewList.previousSibling;
  478. }
  479. data.modelPosition = model.createPositionBefore( modelNode ).getShiftedBy( modelLength );
  480. evt.stop();
  481. }
  482. };
  483. }
  484. /**
  485. * Post-fixer that reacts to changes on document and fixes incorrect model states.
  486. *
  487. * In the example below, there is a correct list structure.
  488. * Then the middle element is removed so the list structure will become incorrect:
  489. *
  490. * <listItem listType="bulleted" listIndent=0>Item 1</listItem>
  491. * <listItem listType="bulleted" listIndent=1>Item 2</listItem> <--- this is removed.
  492. * <listItem listType="bulleted" listIndent=2>Item 3</listItem>
  493. *
  494. * The list structure after the middle element is removed:
  495. *
  496. * <listItem listType="bulleted" listIndent=0>Item 1</listItem>
  497. * <listItem listType="bulleted" listIndent=2>Item 3</listItem>
  498. *
  499. * Should become:
  500. *
  501. * <listItem listType="bulleted" listIndent=0>Item 1</listItem>
  502. * <listItem listType="bulleted" listIndent=1>Item 3</listItem> <--- note that indent got post-fixed.
  503. *
  504. * @param {module:engine/model/model~Model} model The data model.
  505. * @param {module:engine/model/writer~Writer} writer The writer to do changes with.
  506. * @returns {Boolean} `true` if any change has been applied, `false` otherwise.
  507. */
  508. export function modelChangePostFixer( model, writer ) {
  509. const changes = model.document.differ.getChanges();
  510. const itemToListHead = new Map();
  511. let applied = false;
  512. for ( const entry of changes ) {
  513. if ( entry.type == 'insert' && entry.name == 'listItem' ) {
  514. _addListToFix( entry.position );
  515. } else if ( entry.type == 'insert' && entry.name != 'listItem' ) {
  516. if ( entry.name != '$text' ) {
  517. // In case of renamed element.
  518. const item = entry.position.nodeAfter;
  519. if ( item.hasAttribute( 'listIndent' ) ) {
  520. writer.removeAttribute( 'listIndent', item );
  521. applied = true;
  522. }
  523. if ( item.hasAttribute( 'listType' ) ) {
  524. writer.removeAttribute( 'listType', item );
  525. applied = true;
  526. }
  527. for ( const innerItem of Array.from( model.createRangeIn( item ) ).filter( e => e.item.is( 'element', 'listItem' ) ) ) {
  528. _addListToFix( innerItem.previousPosition );
  529. }
  530. }
  531. const posAfter = entry.position.getShiftedBy( entry.length );
  532. _addListToFix( posAfter );
  533. } else if ( entry.type == 'remove' && entry.name == 'listItem' ) {
  534. _addListToFix( entry.position );
  535. } else if ( entry.type == 'attribute' && entry.attributeKey == 'listIndent' ) {
  536. _addListToFix( entry.range.start );
  537. } else if ( entry.type == 'attribute' && entry.attributeKey == 'listType' ) {
  538. _addListToFix( entry.range.start );
  539. }
  540. }
  541. for ( const listHead of itemToListHead.values() ) {
  542. _fixListIndents( listHead );
  543. _fixListTypes( listHead );
  544. }
  545. return applied;
  546. function _addListToFix( position ) {
  547. const previousNode = position.nodeBefore;
  548. if ( !previousNode || !previousNode.is( 'element', 'listItem' ) ) {
  549. const item = position.nodeAfter;
  550. if ( item && item.is( 'element', 'listItem' ) ) {
  551. itemToListHead.set( item, item );
  552. }
  553. } else {
  554. let listHead = previousNode;
  555. if ( itemToListHead.has( listHead ) ) {
  556. return;
  557. }
  558. for (
  559. // Cache previousSibling and reuse for performance reasons. See #6581.
  560. let previousSibling = listHead.previousSibling;
  561. previousSibling && previousSibling.is( 'element', 'listItem' );
  562. previousSibling = listHead.previousSibling
  563. ) {
  564. listHead = previousSibling;
  565. if ( itemToListHead.has( listHead ) ) {
  566. return;
  567. }
  568. }
  569. itemToListHead.set( previousNode, listHead );
  570. }
  571. }
  572. function _fixListIndents( item ) {
  573. let maxIndent = 0;
  574. let fixBy = null;
  575. while ( item && item.is( 'element', 'listItem' ) ) {
  576. const itemIndent = item.getAttribute( 'listIndent' );
  577. if ( itemIndent > maxIndent ) {
  578. let newIndent;
  579. if ( fixBy === null ) {
  580. fixBy = itemIndent - maxIndent;
  581. newIndent = maxIndent;
  582. } else {
  583. if ( fixBy > itemIndent ) {
  584. fixBy = itemIndent;
  585. }
  586. newIndent = itemIndent - fixBy;
  587. }
  588. writer.setAttribute( 'listIndent', newIndent, item );
  589. applied = true;
  590. } else {
  591. fixBy = null;
  592. maxIndent = item.getAttribute( 'listIndent' ) + 1;
  593. }
  594. item = item.nextSibling;
  595. }
  596. }
  597. function _fixListTypes( item ) {
  598. let typesStack = [];
  599. let prev = null;
  600. while ( item && item.is( 'element', 'listItem' ) ) {
  601. const itemIndent = item.getAttribute( 'listIndent' );
  602. if ( prev && prev.getAttribute( 'listIndent' ) > itemIndent ) {
  603. typesStack = typesStack.slice( 0, itemIndent + 1 );
  604. }
  605. if ( itemIndent != 0 ) {
  606. if ( typesStack[ itemIndent ] ) {
  607. const type = typesStack[ itemIndent ];
  608. if ( item.getAttribute( 'listType' ) != type ) {
  609. writer.setAttribute( 'listType', type, item );
  610. applied = true;
  611. }
  612. } else {
  613. typesStack[ itemIndent ] = item.getAttribute( 'listType' );
  614. }
  615. }
  616. prev = item;
  617. item = item.nextSibling;
  618. }
  619. }
  620. }
  621. /**
  622. * A fixer for pasted content that includes list items.
  623. *
  624. * It fixes indentation of pasted list items so the pasted items match correctly to the context they are pasted into.
  625. *
  626. * Example:
  627. *
  628. * <listItem listType="bulleted" listIndent=0>A</listItem>
  629. * <listItem listType="bulleted" listIndent=1>B^</listItem>
  630. * // At ^ paste: <listItem listType="bulleted" listIndent=4>X</listItem>
  631. * // <listItem listType="bulleted" listIndent=5>Y</listItem>
  632. * <listItem listType="bulleted" listIndent=2>C</listItem>
  633. *
  634. * Should become:
  635. *
  636. * <listItem listType="bulleted" listIndent=0>A</listItem>
  637. * <listItem listType="bulleted" listIndent=1>BX</listItem>
  638. * <listItem listType="bulleted" listIndent=2>Y/listItem>
  639. * <listItem listType="bulleted" listIndent=2>C</listItem>
  640. *
  641. * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
  642. * @param {Array} args Arguments of {@link module:engine/model/model~Model#insertContent}.
  643. */
  644. export function modelIndentPasteFixer( evt, [ content, selectable ] ) {
  645. // Check whether inserted content starts from a `listItem`. If it does not, it means that there are some other
  646. // elements before it and there is no need to fix indents, because even if we insert that content into a list,
  647. // that list will be broken.
  648. // Note: we also need to handle singular elements because inserting item with indent 0 into 0,1,[],2
  649. // would create incorrect model.
  650. let item = content.is( 'documentFragment' ) ? content.getChild( 0 ) : content;
  651. let selection;
  652. if ( !selectable ) {
  653. selection = this.document.selection;
  654. } else {
  655. selection = this.createSelection( selectable );
  656. }
  657. if ( item && item.is( 'element', 'listItem' ) ) {
  658. // Get a reference list item. Inserted list items will be fixed according to that item.
  659. const pos = selection.getFirstPosition();
  660. let refItem = null;
  661. if ( pos.parent.is( 'element', 'listItem' ) ) {
  662. refItem = pos.parent;
  663. } else if ( pos.nodeBefore && pos.nodeBefore.is( 'element', 'listItem' ) ) {
  664. refItem = pos.nodeBefore;
  665. }
  666. // If there is `refItem` it means that we do insert list items into an existing list.
  667. if ( refItem ) {
  668. // First list item in `data` has indent equal to 0 (it is a first list item). It should have indent equal
  669. // to the indent of reference item. We have to fix the first item and all of it's children and following siblings.
  670. // Indent of all those items has to be adjusted to reference item.
  671. const indentChange = refItem.getAttribute( 'listIndent' );
  672. // Fix only if there is anything to fix.
  673. if ( indentChange > 0 ) {
  674. // Adjust indent of all "first" list items in inserted data.
  675. while ( item && item.is( 'element', 'listItem' ) ) {
  676. item._setAttribute( 'listIndent', item.getAttribute( 'listIndent' ) + indentChange );
  677. item = item.nextSibling;
  678. }
  679. }
  680. }
  681. }
  682. }
  683. // Helper function that converts children of a given `<li>` view element into corresponding model elements.
  684. // The function maintains proper order of elements if model `listItem` is split during the conversion
  685. // due to block children conversion.
  686. //
  687. // @param {module:engine/model/element~Element} listItemModel List item model element to which converted children will be inserted.
  688. // @param {Iterable.<module:engine/view/node~Node>} viewChildren View elements which will be converted.
  689. // @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface to be used by the callback.
  690. // @returns {module:engine/model/position~Position} Position on which next elements should be inserted after children conversion.
  691. function viewToModelListItemChildrenConverter( listItemModel, viewChildren, conversionApi ) {
  692. const { writer, schema } = conversionApi;
  693. // A position after the last inserted `listItem`.
  694. let nextPosition = writer.createPositionAfter( listItemModel );
  695. // Check all children of the converted `<li>`. At this point we assume there are no "whitespace" view text nodes
  696. // in view list, between view list items. This should be handled by `<ul>` and `<ol>` converters.
  697. for ( const child of viewChildren ) {
  698. if ( child.name == 'ul' || child.name == 'ol' ) {
  699. // If the children is a list, we will insert its conversion result after currently handled `listItem`.
  700. // Then, next insertion position will be set after all the new list items (and maybe other elements if
  701. // something split list item).
  702. //
  703. // If this is a list, we expect that some `listItem`s and possibly other blocks will be inserted, however `.modelCursor`
  704. // should be set after last `listItem` (or block). This is why it feels safe to use it as `nextPosition`
  705. nextPosition = conversionApi.convertItem( child, nextPosition ).modelCursor;
  706. } else {
  707. // If this is not a list, try inserting content at the end of the currently handled `listItem`.
  708. const result = conversionApi.convertItem( child, writer.createPositionAt( listItemModel, 'end' ) );
  709. // It may end up that the current `listItem` becomes split (if that content cannot be inside `listItem`). For example:
  710. //
  711. // <li><p>Foo</p></li>
  712. //
  713. // will be converted to:
  714. //
  715. // <listItem></listItem><paragraph>Foo</paragraph><listItem></listItem>
  716. //
  717. const convertedChild = result.modelRange.start.nodeAfter;
  718. const wasSplit = convertedChild && convertedChild.is( 'element' ) && !schema.checkChild( listItemModel, convertedChild.name );
  719. if ( wasSplit ) {
  720. // As `lastListItem` got split, we need to update it to the second part of the split `listItem` element.
  721. //
  722. // `modelCursor` should be set to a position where the conversion should continue. There are multiple possible scenarios
  723. // that may happen. Usually, `modelCursor` (marked as `#` below) would point to the second list item after conversion:
  724. //
  725. // `<li><p>Foo</p></li>` -> `<listItem></listItem><paragraph>Foo</paragraph><listItem>#</listItem>`
  726. //
  727. // However, in some cases, like auto-paragraphing, the position is placed at the end of the block element:
  728. //
  729. // `<li><div>Foo</div></li>` -> `<listItem></listItem><paragraph>Foo#</paragraph><listItem></listItem>`
  730. //
  731. // or after an element if another element broken auto-paragraphed element:
  732. //
  733. // `<li><div><h2>Foo</h2></div></li>` -> `<listItem></listItem><heading1>Foo</heading1>#<listItem></listItem>`
  734. //
  735. // We need to check for such cases and use proper list item and position based on it.
  736. //
  737. if ( result.modelCursor.parent.is( 'element', 'listItem' ) ) {
  738. // (1).
  739. listItemModel = result.modelCursor.parent;
  740. } else {
  741. // (2), (3).
  742. listItemModel = findNextListItem( result.modelCursor );
  743. }
  744. nextPosition = writer.createPositionAfter( listItemModel );
  745. }
  746. }
  747. }
  748. return nextPosition;
  749. }
  750. // Helper function that seeks for a next list item starting from given `startPosition`.
  751. function findNextListItem( startPosition ) {
  752. const treeWalker = new TreeWalker( { startPosition } );
  753. let value;
  754. do {
  755. value = treeWalker.next();
  756. } while ( !value.value.item.is( 'element', 'listItem' ) );
  757. return value.value.item;
  758. }
  759. // Helper function that takes all children of given `viewRemovedItem` and moves them in a correct place, according
  760. // to other given parameters.
  761. function hoistNestedLists( nextIndent, modelRemoveStartPosition, viewRemoveStartPosition, viewRemovedItem, conversionApi, model ) {
  762. // Find correct previous model list item element.
  763. // The element has to have either same or smaller indent than given reference indent.
  764. // This will be the model element which will get nested items (if it has smaller indent) or sibling items (if it has same indent).
  765. // Keep in mind that such element might not be found, if removed item was the first item.
  766. const prevModelItem = getSiblingListItem( modelRemoveStartPosition.nodeBefore, {
  767. sameIndent: true,
  768. smallerIndent: true,
  769. listIndent: nextIndent,
  770. foo: 'b'
  771. } );
  772. const mapper = conversionApi.mapper;
  773. const viewWriter = conversionApi.writer;
  774. // Indent of found element or `null` if the element has not been found.
  775. const prevIndent = prevModelItem ? prevModelItem.getAttribute( 'listIndent' ) : null;
  776. let insertPosition;
  777. if ( !prevModelItem ) {
  778. // If element has not been found, simply insert lists at the position where the removed item was:
  779. //
  780. // Lorem ipsum.
  781. // 1 -------- <--- this is removed, no previous list item, put nested items in place of removed item.
  782. // 1.1 -------- <--- this is reference indent.
  783. // 1.1.1 --------
  784. // 1.1.2 --------
  785. // 1.2 --------
  786. //
  787. // Becomes:
  788. //
  789. // Lorem ipsum.
  790. // 1.1 --------
  791. // 1.1.1 --------
  792. // 1.1.2 --------
  793. // 1.2 --------
  794. insertPosition = viewRemoveStartPosition;
  795. } else if ( prevIndent == nextIndent ) {
  796. // If element has been found and has same indent as reference indent it means that nested items should
  797. // become siblings of found element:
  798. //
  799. // 1 --------
  800. // 1.1 --------
  801. // 1.2 -------- <--- this is `prevModelItem`.
  802. // 2 -------- <--- this is removed, previous list item has indent same as reference indent.
  803. // 2.1 -------- <--- this is reference indent, this and 2.2 should become siblings of 1.2.
  804. // 2.2 --------
  805. //
  806. // Becomes:
  807. //
  808. // 1 --------
  809. // 1.1 --------
  810. // 1.2 --------
  811. // 2.1 --------
  812. // 2.2 --------
  813. const prevViewList = mapper.toViewElement( prevModelItem ).parent;
  814. insertPosition = viewWriter.createPositionAfter( prevViewList );
  815. } else {
  816. // If element has been found and has smaller indent as reference indent it means that nested items
  817. // should become nested items of found item:
  818. //
  819. // 1 -------- <--- this is `prevModelItem`.
  820. // 1.1 -------- <--- this is removed, previous list item has indent smaller than reference indent.
  821. // 1.1.1 -------- <--- this is reference indent, this and 1.1.1 should become nested items of 1.
  822. // 1.1.2 --------
  823. // 1.2 --------
  824. //
  825. // Becomes:
  826. //
  827. // 1 --------
  828. // 1.1.1 --------
  829. // 1.1.2 --------
  830. // 1.2 --------
  831. //
  832. // Note: in this case 1.1.1 have indent 2 while 1 have indent 0. In model that should not be possible,
  833. // because following item may have indent bigger only by one. But this is fixed by postfixer.
  834. const modelPosition = model.createPositionAt( prevModelItem, 'end' );
  835. insertPosition = mapper.toViewPosition( modelPosition );
  836. }
  837. insertPosition = positionAfterUiElements( insertPosition );
  838. // Handle multiple lists. This happens if list item has nested numbered and bulleted lists. Following lists
  839. // are inserted after the first list (no need to recalculate insertion position for them).
  840. for ( const child of [ ...viewRemovedItem.getChildren() ] ) {
  841. if ( isList( child ) ) {
  842. insertPosition = viewWriter.move( viewWriter.createRangeOn( child ), insertPosition ).end;
  843. mergeViewLists( viewWriter, child, child.nextSibling );
  844. mergeViewLists( viewWriter, child.previousSibling, child );
  845. }
  846. }
  847. }
  848. // Checks if view element is a list type (ul or ol).
  849. //
  850. // @param {module:engine/view/element~Element} viewElement
  851. // @returns {Boolean}
  852. function isList( viewElement ) {
  853. return viewElement.is( 'element', 'ol' ) || viewElement.is( 'element', 'ul' );
  854. }
  855. // Calculates the indent value for a list item. Handles HTML compliant and non-compliant lists.
  856. //
  857. // Also, fixes non HTML compliant lists indents:
  858. //
  859. // before: fixed list:
  860. // OL OL
  861. // |-> LI (parent LIs: 0) |-> LI (indent: 0)
  862. // |-> OL |-> OL
  863. // |-> OL |
  864. // | |-> OL |
  865. // | |-> OL |
  866. // | |-> LI (parent LIs: 1) |-> LI (indent: 1)
  867. // |-> LI (parent LIs: 1) |-> LI (indent: 1)
  868. //
  869. // before: fixed list:
  870. // OL OL
  871. // |-> OL |
  872. // |-> OL |
  873. // |-> OL |
  874. // |-> LI (parent LIs: 0) |-> LI (indent: 0)
  875. //
  876. // before: fixed list:
  877. // OL OL
  878. // |-> LI (parent LIs: 0) |-> LI (indent: 0)
  879. // |-> OL |-> OL
  880. // |-> LI (parent LIs: 0) |-> LI (indent: 1)
  881. //
  882. // @param {module:engine/view/element~Element} listItem
  883. // @param {Object} conversionStore
  884. // @returns {Number}
  885. function getIndent( listItem ) {
  886. let indent = 0;
  887. let parent = listItem.parent;
  888. while ( parent ) {
  889. // Each LI in the tree will result in an increased indent for HTML compliant lists.
  890. if ( parent.is( 'element', 'li' ) ) {
  891. indent++;
  892. } else {
  893. // If however the list is nested in other list we should check previous sibling of any of the list elements...
  894. const previousSibling = parent.previousSibling;
  895. // ...because the we might need increase its indent:
  896. // before: fixed list:
  897. // OL OL
  898. // |-> LI (parent LIs: 0) |-> LI (indent: 0)
  899. // |-> OL |-> OL
  900. // |-> LI (parent LIs: 0) |-> LI (indent: 1)
  901. if ( previousSibling && previousSibling.is( 'element', 'li' ) ) {
  902. indent++;
  903. }
  904. }
  905. parent = parent.parent;
  906. }
  907. return indent;
  908. }