converters.js 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026
  1. /**
  2. * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module list/converters
  7. */
  8. import ViewListItemElement from './viewlistitemelement';
  9. import ModelDocumentFragment from '@ckeditor/ckeditor5-engine/src/model/documentfragment';
  10. import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
  11. import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position';
  12. import modelWriter from '@ckeditor/ckeditor5-engine/src/model/writer';
  13. import ViewContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement';
  14. import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
  15. import ViewRange from '@ckeditor/ckeditor5-engine/src/view/range';
  16. import ViewTreeWalker from '@ckeditor/ckeditor5-engine/src/view/treewalker';
  17. import viewWriter from '@ckeditor/ckeditor5-engine/src/view/writer';
  18. /**
  19. * Model to view converter for `listItem` model element insertion.
  20. *
  21. * It creates `<ul><li></li><ul>` (or `<ol>`) view structure out of `listItem` model element, inserts it at correct
  22. * position, and merges the list with surrounding lists (if able).
  23. *
  24. * @see module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:insert
  25. * @param {module:utils/eventinfo~EventInfo} evt Object containing information about the fired event.
  26. * @param {Object} data Additional information about the change.
  27. * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
  28. * @param {Object} conversionApi Conversion interface.
  29. */
  30. export function modelViewInsertion( evt, data, consumable, conversionApi ) {
  31. if ( !consumable.test( data.item, 'insert' ) ||
  32. !consumable.test( data.item, 'addAttribute:type' ) ||
  33. !consumable.test( data.item, 'addAttribute:indent' )
  34. ) {
  35. return;
  36. }
  37. consumable.consume( data.item, 'insert' );
  38. consumable.consume( data.item, 'addAttribute:type' );
  39. consumable.consume( data.item, 'addAttribute:indent' );
  40. const modelItem = data.item;
  41. const viewItem = generateLiInUl( modelItem, conversionApi.mapper );
  42. // Providing kind of "default" insert position in case of converting incorrect model.
  43. const insertPosition = conversionApi.mapper.toViewPosition( ModelPosition.createBefore( modelItem ) );
  44. injectViewList( modelItem, viewItem, conversionApi.mapper, insertPosition );
  45. }
  46. /**
  47. * Model to view converter for `type` attribute change on `listItem` model element.
  48. *
  49. * This change means that `<li>`s parent changes from `<ul>` to `<ol>` (or vice versa). This is accomplished by breaking
  50. * view elements, changing their name and merging them.
  51. *
  52. * @see module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:changeAttribute
  53. * @param {module:utils/eventinfo~EventInfo} evt Object containing information about the fired event.
  54. * @param {Object} data Additional information about the change.
  55. * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
  56. * @param {Object} conversionApi Conversion interface.
  57. */
  58. export function modelViewChangeType( evt, data, consumable, conversionApi ) {
  59. if ( !consumable.consume( data.item, 'changeAttribute:type' ) ) {
  60. return;
  61. }
  62. const viewItem = conversionApi.mapper.toViewElement( data.item );
  63. // 1. Break the container after and before the list item.
  64. // This will create a view list with one view list item -- the one that changed type.
  65. viewWriter.breakContainer( ViewPosition.createBefore( viewItem ) );
  66. viewWriter.breakContainer( ViewPosition.createAfter( viewItem ) );
  67. // 2. Change name of the view list that holds the changed view item.
  68. // We cannot just change name property, because that would not render properly.
  69. let viewList = viewItem.parent;
  70. const listName = data.attributeNewValue == 'numbered' ? 'ol' : 'ul';
  71. viewList = viewWriter.rename( viewList, listName );
  72. // 3. Merge the changed view list with other lists, if possible.
  73. mergeViewLists( viewList, viewList.nextSibling );
  74. mergeViewLists( viewList.previousSibling, viewList );
  75. }
  76. /**
  77. * Model to view converter for `listItem` model element remove.
  78. *
  79. * @see module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:remove
  80. * @param {module:utils/eventinfo~EventInfo} evt Object containing information about the fired event.
  81. * @param {Object} data Additional information about the change.
  82. * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
  83. * @param {Object} conversionApi Conversion interface.
  84. */
  85. export function modelViewRemove( evt, data, consumable, conversionApi ) {
  86. if ( !consumable.consume( data.item, 'remove' ) ) {
  87. return;
  88. }
  89. const viewPosition = conversionApi.mapper.toViewPosition( data.sourcePosition );
  90. const viewItem = viewPosition.nodeAfter.is( 'li' ) ? viewPosition.nodeAfter : viewPosition.nodeAfter.getChild( 0 );
  91. // 1. Break the container after and before the list item.
  92. // This will create a view list with one view list item -- the one that changed type.
  93. viewWriter.breakContainer( ViewPosition.createBefore( viewItem ) );
  94. viewWriter.breakContainer( ViewPosition.createAfter( viewItem ) );
  95. // 2. Remove the UL that contains just the removed <li>.
  96. const viewList = viewItem.parent;
  97. const viewListPrev = viewList.previousSibling;
  98. const removeRange = ViewRange.createOn( viewList );
  99. viewWriter.remove( removeRange );
  100. if ( viewListPrev && viewListPrev.nextSibling ) {
  101. mergeViewLists( viewListPrev, viewListPrev.nextSibling );
  102. }
  103. // 3. Bring back nested list that was in the removed <li>.
  104. hoistNestedLists( data.item.getAttribute( 'indent' ) + 1, data.sourcePosition, removeRange.start, viewItem, conversionApi.mapper );
  105. // Unbind this element only if it was moved to graveyard.
  106. // See #847.
  107. if ( data.item.root.rootName == '$graveyard' ) {
  108. conversionApi.mapper.unbindModelElement( data.item );
  109. }
  110. }
  111. /**
  112. * Model to view converter for `indent` attribute change on `listItem` model element.
  113. *
  114. * @see module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:changeAttribute
  115. * @param {module:utils/eventinfo~EventInfo} evt Object containing information about the fired event.
  116. * @param {Object} data Additional information about the change.
  117. * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
  118. * @param {Object} conversionApi Conversion interface.
  119. */
  120. export function modelViewChangeIndent( evt, data, consumable, conversionApi ) {
  121. if ( !consumable.consume( data.item, 'changeAttribute:indent' ) ) {
  122. return;
  123. }
  124. const viewItem = conversionApi.mapper.toViewElement( data.item );
  125. // 1. Break the container after and before the list item.
  126. // This will create a view list with one view list item -- the one that changed type.
  127. viewWriter.breakContainer( ViewPosition.createBefore( viewItem ) );
  128. viewWriter.breakContainer( ViewPosition.createAfter( viewItem ) );
  129. // 2. Extract view list with changed view list item and merge "hole" possibly created by breaking and removing elements.
  130. const viewList = viewItem.parent;
  131. const viewListPrev = viewList.previousSibling;
  132. const removeRange = ViewRange.createOn( viewList );
  133. viewWriter.remove( removeRange );
  134. // TODO: get rid of `removePosition` when conversion is done on `changesDone`.
  135. let removePosition;
  136. if ( viewListPrev && viewListPrev.nextSibling ) {
  137. removePosition = mergeViewLists( viewListPrev, viewListPrev.nextSibling );
  138. }
  139. if ( !removePosition ) {
  140. removePosition = removeRange.start;
  141. }
  142. // 3. Bring back nested list that was in the removed <li>.
  143. hoistNestedLists( data.attributeOldValue + 1, data.range.start, removeRange.start, viewItem, conversionApi.mapper );
  144. // 4. Inject view list like it is newly inserted.
  145. injectViewList( data.item, viewItem, conversionApi.mapper, removePosition );
  146. }
  147. /**
  148. * A special model to view converter introduced by {@link module:list/list~List List feature}. This converter is fired for
  149. * insert change of every model item, and should be fired before actual converter. The converter checks whether inserted
  150. * model item is a non-`listItem` element. If it is, and it is inserted inside a view list, the converter breaks the
  151. * list so the model element is inserted to the view parent element corresponding to its model parent element.
  152. *
  153. * The converter prevents such situations:
  154. *
  155. * // Model: // View:
  156. * <listItem>foo</listItem> <ul>
  157. * <listItem>bar</listItem> <li>foo</li>
  158. * <li>bar</li>
  159. * </ul>
  160. *
  161. * // After change: // Correct view guaranteed by this converter:
  162. * <listItem>foo</listItem> <ul><li>foo</li></ul><p>xxx</p><ul><li>bar</li></ul>
  163. * <paragraph>xxx</paragraph> // Instead of this wrong view state:
  164. * <listItem>bar</listItem> <ul><li>foo</li><p>xxx</p><li>bar</li></ul>
  165. *
  166. * @see module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:insert
  167. * @param {module:utils/eventinfo~EventInfo} evt Object containing information about the fired event.
  168. * @param {Object} data Additional information about the change.
  169. * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
  170. * @param {Object} conversionApi Conversion interface.
  171. */
  172. export function modelViewSplitOnInsert( evt, data, consumable, conversionApi ) {
  173. if ( data.item.name != 'listItem' ) {
  174. let viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
  175. const lists = [];
  176. // Break multiple ULs/OLs if there are.
  177. //
  178. // Imagine following list:
  179. //
  180. // 1 --------
  181. // 1.1 --------
  182. // 1.1.1 --------
  183. // 1.1.2 --------
  184. // 1.1.3 --------
  185. // 1.1.3.1 --------
  186. // 1.2 --------
  187. // 1.2.1 --------
  188. // 2 --------
  189. //
  190. // Insert paragraph after item 1.1.1:
  191. //
  192. // 1 --------
  193. // 1.1 --------
  194. // 1.1.1 --------
  195. //
  196. // Lorem ipsum.
  197. //
  198. // 1.1.2 --------
  199. // 1.1.3 --------
  200. // 1.1.3.1 --------
  201. // 1.2 --------
  202. // 1.2.1 --------
  203. // 2 --------
  204. //
  205. // In this case 1.1.2 has to become beginning of a new list.
  206. // We need to break list before 1.1.2 (obvious), then we need to break list also before 1.2.
  207. // Then we need to move those broken pieces one after another and merge:
  208. //
  209. // 1 --------
  210. // 1.1 --------
  211. // 1.1.1 --------
  212. //
  213. // Lorem ipsum.
  214. //
  215. // 1.1.2 --------
  216. // 1.1.3 --------
  217. // 1.1.3.1 --------
  218. // 1.2 --------
  219. // 1.2.1 --------
  220. // 2 --------
  221. //
  222. while ( viewPosition.parent.name == 'ul' || viewPosition.parent.name == 'ol' ) {
  223. viewPosition = viewWriter.breakContainer( viewPosition );
  224. if ( viewPosition.parent.name != 'li' ) {
  225. break;
  226. }
  227. // Remove lists that are after inserted element.
  228. // They will be brought back later, below the inserted element.
  229. const removeStart = viewPosition;
  230. const removeEnd = ViewPosition.createAt( viewPosition.parent, 'end' );
  231. // Don't remove if there is nothing to remove.
  232. if ( !removeStart.isEqual( removeEnd ) ) {
  233. const removed = viewWriter.remove( new ViewRange( removeStart, removeEnd ) );
  234. lists.push( removed );
  235. }
  236. viewPosition = ViewPosition.createAfter( viewPosition.parent );
  237. }
  238. // Bring back removed lists.
  239. if ( lists.length > 0 ) {
  240. for ( let i = 0; i < lists.length; i++ ) {
  241. const previousList = viewPosition.nodeBefore;
  242. const insertedRange = viewWriter.insert( viewPosition, lists[ i ] );
  243. viewPosition = insertedRange.end;
  244. // Don't merge first list! We want a split in that place (this is why this converter is introduced).
  245. if ( i > 0 ) {
  246. const mergePos = mergeViewLists( previousList, previousList.nextSibling );
  247. // If `mergePos` is in `previousList` it means that the lists got merged.
  248. // In this case, we need to fix insert position.
  249. if ( mergePos && mergePos.parent == previousList ) {
  250. viewPosition.offset--;
  251. }
  252. }
  253. }
  254. // Merge last inserted list with element after it.
  255. mergeViewLists( viewPosition.nodeBefore, viewPosition.nodeAfter );
  256. }
  257. }
  258. }
  259. /**
  260. * A special model to view converter introduced by {@link module:list/list~List List feature}. This converter takes care of
  261. * merging view lists after something is removed or moved from near them.
  262. *
  263. * Example:
  264. *
  265. * // Model: // View:
  266. * <listItem>foo</listItem> <ul><li>foo</li></ul>
  267. * <paragraph>xxx</paragraph> <p>xxx</p>
  268. * <listItem>bar</listItem> <ul><li>bar</li></ul>
  269. *
  270. * // After change: // Correct view guaranteed by this converter:
  271. * <listItem>foo</listItem> <ul>
  272. * <listItem>bar</listItem> <li>foo</li>
  273. * <li>bar</li>
  274. * </ul>
  275. *
  276. * @see module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:remove
  277. * @param {module:utils/eventinfo~EventInfo} evt Object containing information about the fired event.
  278. * @param {Object} data Additional information about the change.
  279. * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
  280. * @param {Object} conversionApi Conversion interface.
  281. */
  282. export function modelViewMergeAfter( evt, data, consumable, conversionApi ) {
  283. if ( !data.item.is( 'listItem' ) ) {
  284. const viewPosition = conversionApi.mapper.toViewPosition( data.sourcePosition );
  285. const viewItemPrev = viewPosition.nodeBefore;
  286. const viewItemNext = viewPosition.nodeAfter;
  287. // Merge lists if something (remove, move) was done from inside of list.
  288. // Merging will be done only if both items are view lists of the same type.
  289. // The check is done inside the helper function.
  290. mergeViewLists( viewItemPrev, viewItemNext );
  291. }
  292. }
  293. /**
  294. * View to model converter that converts view `<li>` elements into `listItem` model elements.
  295. *
  296. * To set correct values of `type` and `indent` attribute the converter:
  297. * * checks `<li>`'s parent,
  298. * * passes `data.indent` value when `<li>`'s sub-items are converted.
  299. *
  300. * @see module:engine/conversion/viewconversiondispatcher~ViewConversionDispatcher#event:element
  301. * @param {module:utils/eventinfo~EventInfo} evt Object containing information about the fired event.
  302. * @param {Object} data Object containing conversion input and a placeholder for conversion output and possibly other values.
  303. * @param {module:engine/conversion/viewconsumable~ViewConsumable} consumable Values to consume.
  304. * @param {Object} conversionApi Conversion interface to be used by callback.
  305. */
  306. export function viewModelConverter( evt, data, consumable, conversionApi ) {
  307. if ( consumable.consume( data.input, { name: true } ) ) {
  308. // 1. Create `listItem` model element.
  309. const listItem = new ModelElement( 'listItem' );
  310. // 2. Handle `listItem` model element attributes.
  311. data.indent = data.indent ? data.indent : 0;
  312. const type = data.input.parent.name == 'ul' ? 'bulleted' : 'numbered';
  313. listItem.setAttribute( 'type', type );
  314. listItem.setAttribute( 'indent', data.indent );
  315. // 3. Handle `<li>` children.
  316. data.context.push( listItem );
  317. // `listItem`s created recursively should have bigger indent.
  318. data.indent++;
  319. // `listItem`s will be kept in flat structure.
  320. const items = new ModelDocumentFragment();
  321. items.appendChildren( listItem );
  322. // Check all children of the converted `<li>`.
  323. // At this point we assume there are no "whitespace" view text nodes in view list, between view list items.
  324. // This should be handled by `<ul>` and `<ol>` converters.
  325. for ( const child of data.input.getChildren() ) {
  326. // Let's convert the child.
  327. const converted = conversionApi.convertItem( child, consumable, data );
  328. // If this is a view list element, we will convert it and concat the result (`listItem` model elements)
  329. // with already gathered results (in `items` array). `converted` should be a `ModelDocumentFragment`.
  330. if ( child.name == 'ul' || child.name == 'ol' ) {
  331. items.appendChildren( Array.from( converted.getChildren() ) );
  332. }
  333. // If it was not a list it was a "regular" list item content. Just append it to `listItem`.
  334. else {
  335. modelWriter.insert( ModelPosition.createAt( listItem, 'end' ), converted );
  336. }
  337. }
  338. data.indent--;
  339. data.context.pop();
  340. data.output = items;
  341. }
  342. }
  343. /**
  344. * View to model converter for `<ul>` and `<ol>` view elements, that cleans the input view out of garbage.
  345. * This is mostly to clean white spaces from between `<li>` view elements inside the view list element, however also
  346. * incorrect data can be cleared if the view was incorrect.
  347. *
  348. * @see module:engine/conversion/viewconversiondispatcher~ViewConversionDispatcher#event:element
  349. * @param {module:utils/eventinfo~EventInfo} evt Object containing information about the fired event.
  350. * @param {Object} data Object containing conversion input and a placeholder for conversion output and possibly other values.
  351. * @param {module:engine/conversion/viewconsumable~ViewConsumable} consumable Values to consume.
  352. */
  353. export function cleanList( evt, data, consumable ) {
  354. if ( consumable.test( data.input, { name: true } ) ) {
  355. // Caching children because when we start removing them iterating fails.
  356. const children = Array.from( data.input.getChildren() );
  357. for ( const child of children ) {
  358. if ( !child.is( 'li' ) ) {
  359. child.remove();
  360. }
  361. }
  362. }
  363. }
  364. /**
  365. * View to model converter for `<li>`, that cleans white space formatting from the input view.
  366. *
  367. * @see module:engine/conversion/viewconversiondispatcher~ViewConversionDispatcher#event:element
  368. * @param {module:utils/eventinfo~EventInfo} evt Object containing information about the fired event.
  369. * @param {Object} data Object containing conversion input and a placeholder for conversion output and possibly other values.
  370. * @param {module:engine/conversion/viewconsumable~ViewConsumable} consumable Values to consume.
  371. */
  372. export function cleanListItem( evt, data, consumable ) {
  373. if ( consumable.test( data.input, { name: true } ) ) {
  374. if ( data.input.childCount === 0 ) {
  375. return;
  376. }
  377. const children = [ ...data.input.getChildren() ];
  378. let foundList = false;
  379. let firstNode = true;
  380. for ( const child of children ) {
  381. if ( foundList && !child.is( 'ul' ) && !child.is( 'ol' ) ) {
  382. child.remove();
  383. }
  384. if ( child.is( 'text' ) ) {
  385. // If this is the first node and it's a text node, left-trim it.
  386. if ( firstNode ) {
  387. child.data = child.data.replace( /^\s+/, '' );
  388. }
  389. // If this is the last text node before <ul> or <ol>, right-trim it.
  390. if ( !child.nextSibling || ( child.nextSibling.is( 'ul' ) || child.nextSibling.is( 'ol' ) ) ) {
  391. child.data = child.data.replace( /\s+$/, '' );
  392. }
  393. } else if ( child.is( 'ul' ) || child.is( 'ol' ) ) {
  394. // If this is a <ul> or <ol>, do not process it, just mark that we already visited list element.
  395. foundList = true;
  396. }
  397. firstNode = false;
  398. }
  399. }
  400. }
  401. /**
  402. * Callback for model position to view position mapping for {@link module:engine/conversion/mapper~Mapper}. The callback fixes positions
  403. * between `listItem` elements, that would be incorrectly mapped because of how list items are represented in model
  404. * and view.
  405. *
  406. * @see module:engine/conversion/mapper~Mapper#event:modelToViewPosition
  407. * @param {module:utils/eventinfo~EventInfo} evt Object containing information about the fired event.
  408. * @param {Object} data Object containing additional data and placeholder for mapping result.
  409. */
  410. export function modelToViewPosition( evt, data ) {
  411. const modelItem = data.modelPosition.nodeBefore;
  412. if ( modelItem && modelItem.is( 'listItem' ) ) {
  413. const viewItem = data.mapper.toViewElement( modelItem );
  414. const topmostViewList = viewItem.getAncestors().find( element => element.is( 'ul' ) || element.is( 'ol' ) );
  415. const walker = new ViewTreeWalker( {
  416. startPosition: ViewPosition.createAt( viewItem, 0 )
  417. } );
  418. for ( const value of walker ) {
  419. if ( value.type == 'elementStart' && value.item.is( 'li' ) ) {
  420. data.viewPosition = value.previousPosition;
  421. break;
  422. } else if ( value.type == 'elementEnd' && value.item == topmostViewList ) {
  423. data.viewPosition = value.nextPosition;
  424. break;
  425. }
  426. }
  427. }
  428. }
  429. /**
  430. * Callback for view position to model position mapping for {@link module:engine/conversion/mapper~Mapper}. The callback fixes positions
  431. * between `<li>` elements, that would be incorrectly mapped because of how list items are represented in model
  432. * and view.
  433. *
  434. * @see module:engine/conversion/mapper~Mapper#event:viewToModelPosition
  435. * @param {module:utils/eventinfo~EventInfo} evt Object containing information about the fired event.
  436. * @param {Object} data Object containing additional data and placeholder for mapping result.
  437. */
  438. export function viewToModelPosition( evt, data ) {
  439. const viewPos = data.viewPosition;
  440. const viewParent = viewPos.parent;
  441. const mapper = data.mapper;
  442. if ( viewParent.name == 'ul' || viewParent.name == 'ol' ) {
  443. // Position is directly in <ul> or <ol>.
  444. if ( !viewPos.isAtEnd ) {
  445. // If position is not at the end, it must be before <li>.
  446. // Get that <li>, map it to `listItem` and set model position before that `listItem`.
  447. const modelNode = mapper.toModelElement( viewPos.nodeAfter );
  448. data.modelPosition = ModelPosition.createBefore( modelNode );
  449. } else {
  450. // Position is at the end of <ul> or <ol>, so there is no <li> after it to be mapped.
  451. // There is <li> before the position, but we cannot just map it to `listItem` and set model position after it,
  452. // because that <li> may contain nested items.
  453. // We will check "model length" of that <li>, in other words - how many `listItem`s are in that <li>.
  454. const modelNode = mapper.toModelElement( viewPos.nodeBefore );
  455. const modelLength = mapper.getModelLength( viewPos.nodeBefore );
  456. // Then we get model position before mapped `listItem` and shift it accordingly.
  457. data.modelPosition = ModelPosition.createBefore( modelNode ).getShiftedBy( modelLength );
  458. }
  459. evt.stop();
  460. } else if ( viewParent.name == 'li' && viewPos.nodeBefore && ( viewPos.nodeBefore.name == 'ul' || viewPos.nodeBefore.name == 'ol' ) ) {
  461. // In most cases when view position is in <li> it is in text and this is a correct position.
  462. // However, if position is after <ul> or <ol> we have to fix it -- because in model <ul>/<ol> are not in the `listItem`.
  463. const modelNode = mapper.toModelElement( viewParent );
  464. // Check all <ul>s and <ol>s that are in the <li> but before mapped position.
  465. // Get model length of those elements and then add it to the offset of `listItem` mapped to the original <li>.
  466. let modelLength = 1; // Starts from 1 because the original <li> has to be counted in too.
  467. let viewList = viewPos.nodeBefore;
  468. while ( viewList && ( viewList.is( 'ul' ) || viewList.is( 'ol' ) ) ) {
  469. modelLength += mapper.getModelLength( viewList );
  470. viewList = viewList.previousSibling;
  471. }
  472. data.modelPosition = ModelPosition.createBefore( modelNode ).getShiftedBy( modelLength );
  473. evt.stop();
  474. }
  475. }
  476. /**
  477. * Post fixer that reacts to changes on document and fixed incorrect model states.
  478. *
  479. * Example:
  480. *
  481. * <listItem type="bulleted" indent=0>Item 1</listItem>
  482. * <listItem type="bulleted" indent=1>Item 2</listItem> <--- this is removed.
  483. * <listItem type="bulleted" indent=2>Item 3</listItem>
  484. *
  485. * Should become:
  486. *
  487. * <listItem type="bulleted" indent=0>Item 1</listItem>
  488. * <listItem type="bulleted" indent=1>Item 3</listItem> <--- note that indent got postfixed.
  489. *
  490. * @param {module:engine/model/document~Document} document Document to observe.
  491. * @returns {Function} Callback to be attached to {@link module:engine/model/document~Document#event:change document change event}.
  492. */
  493. export function modelChangePostFixer( document ) {
  494. return ( evt, type, changes, batch ) => {
  495. if ( type == 'remove' ) {
  496. // Fix list items after the cut-out range.
  497. // This fix is needed if items in model after cut-out range have now wrong indents compared to their previous siblings.
  498. _fixItemsIndent( changes.sourcePosition, document, batch );
  499. // This fix is needed if two different nested lists got merged, change types of list items "below".
  500. _fixItemsType( changes.sourcePosition, false, document, batch );
  501. } else if ( type == 'move' ) {
  502. // Fix list items after the cut-out range.
  503. // This fix is needed if items in model after cut-out range have now wrong indents compared to their previous siblings.
  504. _fixItemsIndent( changes.sourcePosition, document, batch );
  505. // This fix is needed if two different nested lists got merged, change types of list items "below".
  506. _fixItemsType( changes.sourcePosition, false, document, batch );
  507. // Fix items in moved range.
  508. // This fix is needed if inserted items are too deeply intended.
  509. _fixItemsIndent( changes.range.start, document, batch );
  510. // This fix is needed if one or more first inserted items have different type.
  511. _fixItemsType( changes.range.start, false, document, batch );
  512. // Fix list items after inserted range.
  513. // This fix is needed if items in model after inserted range have wrong indents.
  514. _fixItemsIndent( changes.range.end, document, batch );
  515. // This fix is needed if one or more last inserted items have different type.
  516. _fixItemsType( changes.range.end, true, document, batch );
  517. } else if ( type == 'rename' && changes.oldName == 'listItem' && changes.newName != 'listItem' ) {
  518. const element = changes.element;
  519. // Element name is changed from list to something else. Remove useless attributes.
  520. document.enqueueChanges( () => {
  521. batch.removeAttribute( element, 'indent' ).removeAttribute( element, 'type' );
  522. } );
  523. const changePos = ModelPosition.createAfter( changes.element );
  524. // Fix list items after the renamed element.
  525. // This fix is needed if there are items after renamed element, those items should start from indent = 0.
  526. _fixItemsIndent( changePos, document, batch );
  527. } else if ( type == 'insert' ) {
  528. // Fix list items in inserted range.
  529. // This fix is needed if inserted items are too deeply intended.
  530. _fixItemsIndent( changes.range.start, document, batch );
  531. // This fix is needed if one or more first inserted items have different type.
  532. _fixItemsType( changes.range.start, false, document, batch );
  533. // Fix list items after inserted range.
  534. // This fix is needed if items in model after inserted range have wrong indents.
  535. _fixItemsIndent( changes.range.end, document, batch );
  536. // This fix is needed if one or more last inserted items have different type.
  537. _fixItemsType( changes.range.end, true, document, batch );
  538. }
  539. };
  540. }
  541. // Helper function for post fixer callback. Performs fixing of model `listElement` items indent attribute. Checks the model at the
  542. // `changePosition`. Looks at the node before position where change occurred and uses that node as a reference for following list items.
  543. function _fixItemsIndent( changePosition, document, batch ) {
  544. let nextItem = changePosition.nodeAfter;
  545. if ( nextItem && nextItem.name == 'listItem' ) {
  546. document.enqueueChanges( () => {
  547. const prevItem = nextItem.previousSibling;
  548. // This is the maximum indent that following model list item may have.
  549. const maxIndent = prevItem && prevItem.is( 'listItem' ) ? prevItem.getAttribute( 'indent' ) + 1 : 0;
  550. // Check how much the next item needs to be outdented.
  551. let outdentBy = nextItem.getAttribute( 'indent' ) - maxIndent;
  552. const items = [];
  553. while ( nextItem && nextItem.name == 'listItem' && nextItem.getAttribute( 'indent' ) > maxIndent ) {
  554. if ( outdentBy > nextItem.getAttribute( 'indent' ) ) {
  555. outdentBy = nextItem.getAttribute( 'indent' );
  556. }
  557. const newIndent = nextItem.getAttribute( 'indent' ) - outdentBy;
  558. items.push( { item: nextItem, indent: newIndent } );
  559. nextItem = nextItem.nextSibling;
  560. }
  561. if ( items.length > 0 ) {
  562. // Since we are outdenting list items, it is safer to start from the last one (it will maintain correct model state).
  563. for ( const item of items.reverse() ) {
  564. batch.setAttribute( item.item, 'indent', item.indent );
  565. }
  566. }
  567. } );
  568. }
  569. }
  570. // Helper function for post fixer callback. Performs fixing of model nested `listElement` items type attribute.
  571. // Checks the model at the `changePosition`. Looks at nodes after/before that position and changes those items type
  572. // to the same as node before/after `changePosition`.
  573. function _fixItemsType( changePosition, fixPrevious, document, batch ) {
  574. let item = changePosition[ fixPrevious ? 'nodeBefore' : 'nodeAfter' ];
  575. if ( !item || !item.is( 'listItem' ) || item.getAttribute( 'indent' ) === 0 ) {
  576. // !item - when last item got removed.
  577. // !item.is( 'listItem' ) - when first element to fix is not a list item already.
  578. // indent === 0 - do not fix if changes are done on top level lists.
  579. return;
  580. }
  581. document.enqueueChanges( () => {
  582. const refItem = _getBoundaryItemOfSameList( item, !fixPrevious );
  583. if ( !refItem || refItem == item ) {
  584. // !refItem - happens if first list item is inserted.
  585. // refItem == item - happens if last item is inserted.
  586. return;
  587. }
  588. const refIndent = refItem.getAttribute( 'indent' );
  589. const refType = refItem.getAttribute( 'type' );
  590. while ( item && item.is( 'listItem' ) && item.getAttribute( 'indent' ) >= refIndent ) {
  591. if ( item.getAttribute( 'type' ) != refType && item.getAttribute( 'indent' ) == refIndent ) {
  592. batch.setAttribute( item, 'type', refType );
  593. }
  594. item = item[ fixPrevious ? 'previousSibling' : 'nextSibling' ];
  595. }
  596. } );
  597. }
  598. /**
  599. * Fixer for pasted content that includes list items.
  600. *
  601. * Fixes indent of pasted list items so the pasted items match correctly to the context they are pasted into.
  602. *
  603. * Example:
  604. *
  605. * <listItem type="bulleted" indent=0>A</listItem>
  606. * <listItem type="bulleted" indent=1>B^</listItem>
  607. * // At ^ paste: <listItem type="bulleted" indent=4>X</listItem>
  608. * // <listItem type="bulleted" indent=5>Y</listItem>
  609. * <listItem type="bulleted" indent=2>C</listItem>
  610. *
  611. * Should become:
  612. *
  613. * <listItem type="bulleted" indent=0>A</listItem>
  614. * <listItem type="bulleted" indent=1>BX</listItem>
  615. * <listItem type="bulleted" indent=2>Y/listItem>
  616. * <listItem type="bulleted" indent=2>C</listItem>
  617. *
  618. * @param {module:engine/model/document~Document} document Document to observe.
  619. * @returns {Function} Callback to be attached to {@link module:engine/model/document~Document#event:change document change event}.
  620. */
  621. export function modelIndentPasteFixer( evt, data ) {
  622. // Check whether inserted content starts from a `listItem`. If it does not, it means that there are some other
  623. // elements before it and there is no need to fix indents, because even if we insert that content into a list,
  624. // that list will be broken.
  625. let item = data.content.getChild( 0 );
  626. if ( item.is( 'listItem' ) ) {
  627. // Get a reference list item. Inserted list items will be fixed according to that item.
  628. const pos = data.selection.getFirstPosition();
  629. let refItem = null;
  630. if ( pos.parent.is( 'listItem' ) ) {
  631. refItem = pos.parent;
  632. } else if ( pos.nodeBefore && pos.nodeBefore.is( 'listItem' ) ) {
  633. refItem = pos.nodeBefore;
  634. }
  635. // If there is `refItem` it means that we do insert list items into an existing list.
  636. if ( refItem ) {
  637. // First list item in `data` has indent equal to 0 (it is a first list item). It should have indent equal
  638. // to the indent of reference item. We have to fix the first item and all of it's children and following siblings.
  639. // Indent of all those items has to be adjusted to reference item.
  640. const indentChange = refItem.getAttribute( 'indent' );
  641. // Fix only if there is anything to fix.
  642. if ( indentChange > 0 ) {
  643. // Adjust indent of all "first" list items in inserted data.
  644. while ( item && item.is( 'listItem' ) ) {
  645. item.setAttribute( 'indent', item.getAttribute( 'indent' ) + indentChange );
  646. item = item.nextSibling;
  647. }
  648. }
  649. }
  650. }
  651. }
  652. // Helper function that creates a `<ul><li></li></ul>` or (`<ol>`) structure out of given `modelItem` model `listItem` element.
  653. // Then, it binds created view list item (<li>) with model `listItem` element.
  654. // The function then returns created view list item (<li>).
  655. function generateLiInUl( modelItem, mapper ) {
  656. const listType = modelItem.getAttribute( 'type' ) == 'numbered' ? 'ol' : 'ul';
  657. const viewItem = new ViewListItemElement();
  658. const viewList = new ViewContainerElement( listType, null );
  659. viewList.appendChildren( viewItem );
  660. mapper.bindElements( modelItem, viewItem );
  661. return viewItem;
  662. }
  663. // Helper function that seeks for a list item sibling of given model item (or position) which meets given criteria.
  664. // `options` object may contain one or more of given values (by default they are `false`):
  665. // `options.getNext` - whether next or previous siblings should be checked (default = previous)
  666. // `options.checkAllSiblings` - whether all siblings or just the first one should be checked (default = only one),
  667. // `options.sameIndent` - whether sought sibling should have same indent (default = no),
  668. // `options.biggerIndent` - whether sought sibling should have bigger indent (default = no).
  669. // `options.smallerIndent` - whether sought sibling should have smaller indent (default = no).
  670. // `options.isMapped` - whether sought sibling must be mapped to view (default = no).
  671. // `options.mapper` - used to map model elements when `isMapped` option is set to true.
  672. // `options.indent` - used as reference item when first parameter is a position
  673. // Either `options.sameIndent` or `options.biggerIndent` should be set to `true`.
  674. function getSiblingListItem( modelItemOrPosition, options ) {
  675. const direction = options.getNext ? 'nextSibling' : 'previousSibling';
  676. const posDirection = options.getNext ? 'nodeAfter' : 'nodeBefore';
  677. const checkAllSiblings = !!options.checkAllSiblings;
  678. const sameIndent = !!options.sameIndent;
  679. const biggerIndent = !!options.biggerIndent;
  680. const smallerIndent = !!options.smallerIndent;
  681. const isMapped = !!options.isMapped;
  682. const indent = modelItemOrPosition instanceof ModelElement ? modelItemOrPosition.getAttribute( 'indent' ) : options.indent;
  683. let item = modelItemOrPosition instanceof ModelElement ? modelItemOrPosition[ direction ] : modelItemOrPosition[ posDirection ];
  684. while ( item && item.name == 'listItem' ) {
  685. const itemIndent = item.getAttribute( 'indent' );
  686. if (
  687. ( sameIndent && indent == itemIndent ) ||
  688. ( biggerIndent && indent < itemIndent ) ||
  689. ( smallerIndent && indent > itemIndent )
  690. ) {
  691. if ( !isMapped || options.mapper.toViewElement( item ) ) {
  692. return item;
  693. } else {
  694. item = item[ direction ];
  695. continue;
  696. }
  697. }
  698. if ( !checkAllSiblings ) {
  699. return null;
  700. }
  701. item = item[ direction ];
  702. }
  703. return null;
  704. }
  705. // Helper function that takes two parameters, that are expected to be view list elements, and merges them.
  706. // The merge happen only if both parameters are UL or OL elements.
  707. function mergeViewLists( firstList, secondList ) {
  708. if ( firstList && secondList && ( firstList.name == 'ul' || firstList.name == 'ol' ) && firstList.name == secondList.name ) {
  709. return viewWriter.mergeContainers( ViewPosition.createAfter( firstList ) );
  710. }
  711. return null;
  712. }
  713. // Helper function that takes model list item element `modelItem`, corresponding view list item element `injectedItem`
  714. // that is not added to the view and is inside a view list element (`ul` or `ol`) and is that's list only child.
  715. // The list is inserted at correct position (element breaking may be needed) and then merged with it's siblings.
  716. // See comments below to better understand the algorithm.
  717. function injectViewList( modelItem, injectedItem, mapper, removePosition ) {
  718. const injectedList = injectedItem.parent;
  719. // Position where view list will be inserted.
  720. let insertPosition;
  721. // 1. Find previous list item that has same or smaller indent. Basically we are looking for a first model item
  722. // that is "parent" or "sibling" if injected model item.
  723. // If there is no such list item, it means that injected list item is the first item in "its list".
  724. let prevItem = getSiblingListItem( modelItem, { sameIndent: true, smallerIndent: true, checkAllSiblings: true } );
  725. if ( prevItem && prevItem.getAttribute( 'indent' ) == modelItem.getAttribute( 'indent' ) ) {
  726. // There is a list item with same indent - we found same-level sibling.
  727. // Break the list after it. Inserted view item will be inserted in the broken space.
  728. const viewItem = mapper.toViewElement( prevItem );
  729. insertPosition = viewWriter.breakContainer( ViewPosition.createAfter( viewItem ) );
  730. } else {
  731. // There is no list item with same indent. Check previous model item.
  732. prevItem = modelItem.previousSibling;
  733. if ( prevItem && prevItem.name == 'listItem' ) {
  734. // // If it is a list item, it has to have lower indent.
  735. // // It means that inserted item should be added to it as its nested item.
  736. // insertPosition = mapper.toViewPosition( ModelPosition.createAt( prevItem, 'end' ) );
  737. // ^ ACTUALLY NOT BECAUSE FIXING DOES NOT WORK PROPERLY.
  738. // TODO: fix this part of code when conversion from model to view is done on `changesDone` event or post/prefixing is better.
  739. if ( prevItem.getAttribute( 'indent' ) < modelItem.getAttribute( 'indent' ) ) {
  740. // Lower indent, correct model, previous item is a parent and this model item is its nested item.
  741. insertPosition = mapper.toViewPosition( ModelPosition.createAt( prevItem, 'end' ) );
  742. } else {
  743. // Higher indent, incorrect model that is probably being fixed. Inject the view list where it was.
  744. // TODO: get rid of `removePosition` when conversion is done on `changesDone`.
  745. if ( removePosition.parent.is( 'ul' ) || removePosition.parent.is( 'ol' ) ) {
  746. insertPosition = viewWriter.breakContainer( removePosition );
  747. } else {
  748. insertPosition = removePosition;
  749. }
  750. }
  751. } else {
  752. // Previous item is not a list item (or does not exist at all).
  753. // Just map the position and insert the view item at mapped position.
  754. insertPosition = mapper.toViewPosition( ModelPosition.createBefore( modelItem ) );
  755. }
  756. }
  757. // Insert the view item.
  758. viewWriter.insert( insertPosition, injectedList );
  759. // 2. Handle possible children of injected model item.
  760. // We have to check if next list item in model has bigger indent. If it has, it means that it and possibly
  761. // some following list items should be nested in the injected view item.
  762. // Look only after model elements that are already mapped to view. Some following model items might not be mapped
  763. // if multiple items in model were inserted/moved at once.
  764. const nextItem = getSiblingListItem(
  765. modelItem,
  766. { biggerIndent: true, getNext: true, isMapped: true, mapper }
  767. );
  768. if ( nextItem ) {
  769. const viewItem = mapper.toViewElement( nextItem );
  770. // Break the list between found view item and its preceding `<li>`s.
  771. viewWriter.breakContainer( ViewPosition.createBefore( viewItem ) );
  772. // The broken ("lower") part will be moved as nested children of the inserted view item.
  773. const sourceStart = ViewPosition.createBefore( viewItem.parent );
  774. const lastModelItem = _getBoundaryItemOfSameList( nextItem, false );
  775. const lastViewItem = mapper.toViewElement( lastModelItem );
  776. const sourceEnd = viewWriter.breakContainer( ViewPosition.createAfter( lastViewItem ) );
  777. const sourceRange = new ViewRange( sourceStart, sourceEnd );
  778. const targetPosition = ViewPosition.createAt( injectedItem, 'end' );
  779. viewWriter.move( sourceRange, targetPosition );
  780. }
  781. // Merge inserted view list with its possible neighbour lists.
  782. mergeViewLists( injectedList, injectedList.nextSibling );
  783. mergeViewLists( injectedList.previousSibling, injectedList );
  784. }
  785. // Helper function that takes all children of given `viewRemovedItem` and moves them in a correct place, according
  786. // to other given parameters.
  787. function hoistNestedLists( nextIndent, modelRemoveStartPosition, viewRemoveStartPosition, viewRemovedItem, mapper ) {
  788. // Find correct previous model list item element.
  789. // The element has to have either same or smaller indent than given reference indent.
  790. // This will be the model element which will get nested items (if it has smaller indent) or sibling items (if it has same indent).
  791. // Keep in mind that such element might not be found, if removed item was the first item.
  792. const prevModelItem = getSiblingListItem( modelRemoveStartPosition, {
  793. sameIndent: true,
  794. smallerIndent: true,
  795. checkAllSiblings: true,
  796. indent: nextIndent
  797. } );
  798. // Indent of found element or `null` if the element has not been found.
  799. const prevIndent = prevModelItem ? prevModelItem.getAttribute( 'indent' ) : null;
  800. let insertPosition;
  801. if ( !prevModelItem ) {
  802. // If element has not been found, simply insert lists at the position where the removed item was:
  803. //
  804. // Lorem ipsum.
  805. // 1 -------- <--- this is removed, no previous list item, put nested items in place of removed item.
  806. // 1.1 -------- <--- this is reference indent.
  807. // 1.1.1 --------
  808. // 1.1.2 --------
  809. // 1.2 --------
  810. //
  811. // Becomes:
  812. //
  813. // Lorem ipsum.
  814. // 1.1 --------
  815. // 1.1.1 --------
  816. // 1.1.2 --------
  817. // 1.2 --------
  818. insertPosition = viewRemoveStartPosition;
  819. } else if ( prevIndent == nextIndent ) {
  820. // If element has been found and has same indent as reference indent it means that nested items should
  821. // become siblings of found element:
  822. //
  823. // 1 --------
  824. // 1.1 --------
  825. // 1.2 -------- <--- this is `prevModelItem`.
  826. // 2 -------- <--- this is removed, previous list item has indent same as reference indent.
  827. // 2.1 -------- <--- this is reference indent, this and 2.2 should become siblings of 1.2.
  828. // 2.2 --------
  829. //
  830. // Becomes:
  831. //
  832. // 1 --------
  833. // 1.1 --------
  834. // 1.2 --------
  835. // 2.1 --------
  836. // 2.2 --------
  837. const prevViewList = mapper.toViewElement( prevModelItem ).parent;
  838. insertPosition = ViewPosition.createAfter( prevViewList );
  839. } else {
  840. // If element has been found and has smaller indent as reference indent it means that nested items
  841. // should become nested items of found item:
  842. //
  843. // 1 -------- <--- this is `prevModelItem`.
  844. // 1.1 -------- <--- this is removed, previous list item has indent smaller than reference indent.
  845. // 1.1.1 -------- <--- this is reference indent, this and 1.1.1 should become nested items of 1.
  846. // 1.1.2 --------
  847. // 1.2 --------
  848. //
  849. // Becomes:
  850. //
  851. // 1 --------
  852. // 1.1.1 --------
  853. // 1.1.2 --------
  854. // 1.2 --------
  855. //
  856. // Note: in this case 1.1.1 have indent 2 while 1 have indent 0. In model that should not be possible,
  857. // because following item may have indent bigger only by one. But this is fixed by postfixer.
  858. const modelPosition = ModelPosition.createAt( prevModelItem, 'end' );
  859. insertPosition = mapper.toViewPosition( modelPosition );
  860. }
  861. // Handle multiple lists. This happens if list item has nested numbered and bulleted lists. Following lists
  862. // are inserted after the first list (no need to recalculate insertion position for them).
  863. for ( const child of [ ...viewRemovedItem.getChildren() ] ) {
  864. if ( child.is( 'ul' ) || child.is( 'ol' ) ) {
  865. insertPosition = viewWriter.move( ViewRange.createOn( child ), insertPosition ).end;
  866. mergeViewLists( child, child.nextSibling );
  867. mergeViewLists( child.previousSibling, child );
  868. }
  869. }
  870. }
  871. // Helper function to obtain the first or the last model list item which is in on the same indent level as given `item`.
  872. function _getBoundaryItemOfSameList( item, getFirst ) {
  873. const indent = item.getAttribute( 'indent' );
  874. const direction = getFirst ? 'previousSibling' : 'nextSibling';
  875. let result = item;
  876. while ( item[ direction ] && item[ direction ].is( 'listItem' ) && item[ direction ].getAttribute( 'indent' ) >= indent ) {
  877. item = item[ direction ];
  878. if ( item.getAttribute( 'indent' ) == indent ) {
  879. result = item;
  880. }
  881. }
  882. return result;
  883. }