converters.js 41 KB

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