converters.js 40 KB

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