converters.js 43 KB

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