mapper.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  1. /**
  2. * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module engine/conversion/mapper
  7. */
  8. import ModelPosition from '../model/position';
  9. import ModelRange from '../model/range';
  10. import ViewPosition from '../view/position';
  11. import ViewRange from '../view/range';
  12. import ViewText from '../view/text';
  13. import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
  14. import mix from '@ckeditor/ckeditor5-utils/src/mix';
  15. /**
  16. * Maps elements and positions between {@link module:engine/view/document~Document view} and {@link module:engine/model/model model}.
  17. *
  18. * Mapper use bound elements to find corresponding elements and positions, so, to get proper results,
  19. * all model elements should be {@link module:engine/conversion/mapper~Mapper#bindElements bound}.
  20. *
  21. * To map complex model to/from view relations, you may provide custom callbacks for
  22. * {@link module:engine/conversion/mapper~Mapper#event:modelToViewPosition modelToViewPosition event} and
  23. * {@link module:engine/conversion/mapper~Mapper#event:viewToModelPosition viewToModelPosition event} that are fired whenever
  24. * a position mapping request occurs.
  25. * Those events are fired by {@link module:engine/conversion/mapper~Mapper#toViewPosition toViewPosition}
  26. * and {@link module:engine/conversion/mapper~Mapper#toModelPosition toModelPosition} methods. `Mapper` adds it's own default callbacks
  27. * with `'lowest'` priority. To override default `Mapper` mapping, add custom callback with higher priority and
  28. * stop the event.
  29. */
  30. export default class Mapper {
  31. /**
  32. * Creates an instance of the mapper.
  33. */
  34. constructor() {
  35. /**
  36. * Model element to view element mapping.
  37. *
  38. * @private
  39. * @member {WeakMap}
  40. */
  41. this._modelToViewMapping = new WeakMap();
  42. /**
  43. * View element to model element mapping.
  44. *
  45. * @private
  46. * @member {WeakMap}
  47. */
  48. this._viewToModelMapping = new WeakMap();
  49. /**
  50. * A map containing callbacks between view element names and functions evaluating length of view elements
  51. * in model.
  52. *
  53. * @private
  54. * @member {Map}
  55. */
  56. this._viewToModelLengthCallbacks = new Map();
  57. // Default mapper algorithm for mapping model position to view position.
  58. this.on( 'modelToViewPosition', ( evt, data ) => {
  59. if ( data.viewPosition ) {
  60. return;
  61. }
  62. let viewContainer = this._modelToViewMapping.get( data.modelPosition.parent );
  63. data.viewPosition = this._findPositionIn( viewContainer, data.modelPosition.offset );
  64. }, { priority: 'low' } );
  65. // Default mapper algorithm for mapping view position to model position.
  66. this.on( 'viewToModelPosition', ( evt, data ) => {
  67. if ( data.modelPosition ) {
  68. return;
  69. }
  70. let viewBlock = data.viewPosition.parent;
  71. let modelParent = this._viewToModelMapping.get( viewBlock );
  72. while ( !modelParent ) {
  73. viewBlock = viewBlock.parent;
  74. modelParent = this._viewToModelMapping.get( viewBlock );
  75. }
  76. let modelOffset = this._toModelOffset( data.viewPosition.parent, data.viewPosition.offset, viewBlock );
  77. data.modelPosition = ModelPosition.createFromParentAndOffset( modelParent, modelOffset );
  78. }, { priority: 'low' } );
  79. }
  80. /**
  81. * Marks model and view elements as corresponding. Corresponding elements can be retrieved by using
  82. * the {@link module:engine/conversion/mapper~Mapper#toModelElement toModelElement} and
  83. * {@link module:engine/conversion/mapper~Mapper#toViewElement toViewElement} methods.
  84. * The information that elements are bound is also used to translate positions.
  85. *
  86. * @param {module:engine/model/element~Element} modelElement Model element.
  87. * @param {module:engine/view/element~Element} viewElement View element.
  88. */
  89. bindElements( modelElement, viewElement ) {
  90. this._modelToViewMapping.set( modelElement, viewElement );
  91. this._viewToModelMapping.set( viewElement, modelElement );
  92. }
  93. /**
  94. * Unbinds given {@link module:engine/view/element~Element view element} from the map.
  95. *
  96. * @param {module:engine/view/element~Element} viewElement View element to unbind.
  97. */
  98. unbindViewElement( viewElement ) {
  99. const modelElement = this.toModelElement( viewElement );
  100. this._unbindElements( modelElement, viewElement );
  101. }
  102. /**
  103. * Unbinds given {@link module:engine/model/element~Element model element} from the map.
  104. *
  105. * @param {module:engine/model/element~Element} modelElement Model element to unbind.
  106. */
  107. unbindModelElement( modelElement ) {
  108. const viewElement = this.toViewElement( modelElement );
  109. this._unbindElements( modelElement, viewElement );
  110. }
  111. /**
  112. * Removes all model to view and view to model bindings.
  113. */
  114. clearBindings() {
  115. this._modelToViewMapping = new WeakMap();
  116. this._viewToModelMapping = new WeakMap();
  117. }
  118. /**
  119. * Gets the corresponding model element.
  120. *
  121. * **Note:** {@link module:engine/view/uielement~UIElement} does not have corresponding element in model.
  122. *
  123. * @param {module:engine/view/element~Element} viewElement View element.
  124. * @returns {module:engine/model/element~Element|undefined} Corresponding model element or `undefined` if not found.
  125. */
  126. toModelElement( viewElement ) {
  127. return this._viewToModelMapping.get( viewElement );
  128. }
  129. /**
  130. * Gets the corresponding view element.
  131. *
  132. * @param {module:engine/model/element~Element} modelElement Model element.
  133. * @returns {module:engine/view/element~Element|undefined} Corresponding view element or `undefined` if not found.
  134. */
  135. toViewElement( modelElement ) {
  136. return this._modelToViewMapping.get( modelElement );
  137. }
  138. /**
  139. * Gets the corresponding model range.
  140. *
  141. * @param {module:engine/view/range~Range} viewRange View range.
  142. * @returns {module:engine/model/range~Range} Corresponding model range.
  143. */
  144. toModelRange( viewRange ) {
  145. return new ModelRange( this.toModelPosition( viewRange.start ), this.toModelPosition( viewRange.end ) );
  146. }
  147. /**
  148. * Gets the corresponding view range.
  149. *
  150. * @param {module:engine/model/range~Range} modelRange Model range.
  151. * @returns {module:engine/view/range~Range} Corresponding view range.
  152. */
  153. toViewRange( modelRange ) {
  154. return new ViewRange( this.toViewPosition( modelRange.start ), this.toViewPosition( modelRange.end ) );
  155. }
  156. /**
  157. * Gets the corresponding model position.
  158. *
  159. * @fires viewToModelPosition
  160. * @param {module:engine/view/position~Position} viewPosition View position.
  161. * @returns {module:engine/model/position~Position} Corresponding model position.
  162. */
  163. toModelPosition( viewPosition ) {
  164. const data = {
  165. viewPosition: viewPosition,
  166. mapper: this
  167. };
  168. this.fire( 'viewToModelPosition', data );
  169. return data.modelPosition;
  170. }
  171. /**
  172. * Gets the corresponding view position.
  173. *
  174. * @fires modelToViewPosition
  175. * @param {module:engine/model/position~Position} modelPosition Model position.
  176. * @returns {module:engine/view/position~Position} Corresponding view position.
  177. */
  178. toViewPosition( modelPosition ) {
  179. const data = {
  180. modelPosition: modelPosition,
  181. mapper: this
  182. };
  183. this.fire( 'modelToViewPosition', data );
  184. return data.viewPosition;
  185. }
  186. /**
  187. * Registers a callback that evaluates the length in the model of a view element with given name.
  188. *
  189. * The callback is fired with one argument, which is a view element instance. The callback is expected to return
  190. * a number representing the length of view element in model.
  191. *
  192. * // List item in view may contain nested list, which have other list items. In model though,
  193. * // the lists are represented by flat structure. Because of those differences, length of list view element
  194. * // may be greater than one. In the callback it's checked how many nested list items are in evaluated list item.
  195. *
  196. * function getViewListItemLength( element ) {
  197. * let length = 1;
  198. *
  199. * for ( let child of element.getChildren() ) {
  200. * if ( child.name == 'ul' || child.name == 'ol' ) {
  201. * for ( let item of child.getChildren() ) {
  202. * length += getViewListItemLength( item );
  203. * }
  204. * }
  205. * }
  206. *
  207. * return length;
  208. * }
  209. *
  210. * mapper.registerViewToModelLength( 'li', getViewListItemLength );
  211. *
  212. * @param {String} viewElementName Name of view element for which callback is registered.
  213. * @param {Function} lengthCallback Function return a length of view element instance in model.
  214. */
  215. registerViewToModelLength( viewElementName, lengthCallback ) {
  216. this._viewToModelLengthCallbacks.set( viewElementName, lengthCallback );
  217. }
  218. /**
  219. * Calculates model offset based on the view position and the block element.
  220. *
  221. * Example:
  222. *
  223. * <p>foo<b>ba|r</b></p> // _toModelOffset( b, 2, p ) -> 5
  224. *
  225. * Is a sum of:
  226. *
  227. * <p>foo|<b>bar</b></p> // _toModelOffset( p, 3, p ) -> 3
  228. * <p>foo<b>ba|r</b></p> // _toModelOffset( b, 2, b ) -> 2
  229. *
  230. * @private
  231. * @param {module:engine/view/element~Element} viewParent Position parent.
  232. * @param {Number} viewOffset Position offset.
  233. * @param {module:engine/view/element~Element} viewBlock Block used as a base to calculate offset.
  234. * @returns {Number} Offset in the model.
  235. */
  236. _toModelOffset( viewParent, viewOffset, viewBlock ) {
  237. if ( viewBlock != viewParent ) {
  238. // See example.
  239. const offsetToParentStart = this._toModelOffset( viewParent.parent, viewParent.index, viewBlock );
  240. const offsetInParent = this._toModelOffset( viewParent, viewOffset, viewParent );
  241. return offsetToParentStart + offsetInParent;
  242. }
  243. // viewBlock == viewParent, so we need to calculate the offset in the parent element.
  244. // If the position is a text it is simple ("ba|r" -> 2).
  245. if ( viewParent.is( 'text' ) ) {
  246. return viewOffset;
  247. }
  248. // If the position is in an element we need to sum lengths of siblings ( <b> bar </b> foo | -> 3 + 3 = 6 ).
  249. let modelOffset = 0;
  250. for ( let i = 0; i < viewOffset; i++ ) {
  251. modelOffset += this.getModelLength( viewParent.getChild( i ) );
  252. }
  253. return modelOffset;
  254. }
  255. /**
  256. * Removes binding between given elements.
  257. *
  258. * @private
  259. * @param {module:engine/model/element~Element} modelElement Model element to unbind.
  260. * @param {module:engine/view/element~Element} viewElement View element to unbind.
  261. */
  262. _unbindElements( modelElement, viewElement ) {
  263. this._viewToModelMapping.delete( viewElement );
  264. this._modelToViewMapping.delete( modelElement );
  265. }
  266. /**
  267. * Gets the length of the view element in the model.
  268. *
  269. * The length is calculated as follows:
  270. * * if {@link #registerViewToModelLength length mapping callback} is provided for given `viewNode` it is used to
  271. * evaluate model length (`viewNode` is used as first and only parameter passed to the callback),
  272. * * length of a {@link module:engine/view/text~Text text node} is equal to the length of it's
  273. * {@link module:engine/view/text~Text#data data},
  274. * * length of a {@link module:engine/view/uielement~UIElement ui element} is equal to 0,
  275. * * length of a mapped {@link module:engine/view/element~Element element} is equal to 1,
  276. * * length of a not-mapped {@link module:engine/view/element~Element element} is equal to the length of it's children.
  277. *
  278. * Examples:
  279. *
  280. * foo -> 3 // Text length is equal to it's data length.
  281. * <p>foo</p> -> 1 // Length of an element which is mapped is by default equal to 1.
  282. * <b>foo</b> -> 3 // Length of an element which is not mapped is a length of its children.
  283. * <div><p>x</p><p>y</p></div> -> 2 // Assuming that <div> is not mapped and <p> are mapped.
  284. *
  285. * @param {module:engine/view/element~Element} viewNode View node.
  286. * @returns {Number} Length of the node in the tree model.
  287. */
  288. getModelLength( viewNode ) {
  289. if ( this._viewToModelLengthCallbacks.get( viewNode.name ) ) {
  290. const callback = this._viewToModelLengthCallbacks.get( viewNode.name );
  291. return callback( viewNode );
  292. } else if ( this._viewToModelMapping.has( viewNode ) ) {
  293. return 1;
  294. } else if ( viewNode.is( 'text' ) ) {
  295. return viewNode.data.length;
  296. } else if ( viewNode.is( 'uiElement' ) ) {
  297. return 0;
  298. } else {
  299. let len = 0;
  300. for ( let child of viewNode.getChildren() ) {
  301. len += this.getModelLength( child );
  302. }
  303. return len;
  304. }
  305. }
  306. /**
  307. * Finds the position in the view node (or its children) with the expected model offset.
  308. *
  309. * Example:
  310. *
  311. * <p>fo<b>bar</b>bom</p> -> expected offset: 4
  312. *
  313. * _findPositionIn( p, 4 ):
  314. * <p>|fo<b>bar</b>bom</p> -> expected offset: 4, actual offset: 0
  315. * <p>fo|<b>bar</b>bom</p> -> expected offset: 4, actual offset: 2
  316. * <p>fo<b>bar</b>|bom</p> -> expected offset: 4, actual offset: 5 -> we are too far
  317. *
  318. * _findPositionIn( b, 4 - ( 5 - 3 ) ):
  319. * <p>fo<b>|bar</b>bom</p> -> expected offset: 2, actual offset: 0
  320. * <p>fo<b>bar|</b>bom</p> -> expected offset: 2, actual offset: 3 -> we are too far
  321. *
  322. * _findPositionIn( bar, 2 - ( 3 - 3 ) ):
  323. * We are in the text node so we can simple find the offset.
  324. * <p>fo<b>ba|r</b>bom</p> -> expected offset: 2, actual offset: 2 -> position found
  325. *
  326. * @private
  327. * @param {module:engine/view/element~Element} viewParent Tree view element in which we are looking for the position.
  328. * @param {Number} expectedOffset Expected offset.
  329. * @returns {module:engine/view/position~Position} Found position.
  330. */
  331. _findPositionIn( viewParent, expectedOffset ) {
  332. // Last scanned view node.
  333. let viewNode;
  334. // Length of the last scanned view node.
  335. let lastLength = 0;
  336. let modelOffset = 0;
  337. let viewOffset = 0;
  338. // In the text node it is simple: offset in the model equals offset in the text.
  339. if ( viewParent.is( 'text' ) ) {
  340. return new ViewPosition( viewParent, expectedOffset );
  341. }
  342. // In other cases we add lengths of child nodes to find the proper offset.
  343. // If it is smaller we add the length.
  344. while ( modelOffset < expectedOffset ) {
  345. viewNode = viewParent.getChild( viewOffset );
  346. lastLength = this.getModelLength( viewNode );
  347. modelOffset += lastLength;
  348. viewOffset++;
  349. }
  350. // If it equals we found the position.
  351. if ( modelOffset == expectedOffset ) {
  352. return this._moveViewPositionToTextNode( new ViewPosition( viewParent, viewOffset ) );
  353. }
  354. // If it is higher we need to enter last child.
  355. else {
  356. // ( modelOffset - lastLength ) is the offset to the child we enter,
  357. // so we subtract it from the expected offset to fine the offset in the child.
  358. return this._findPositionIn( viewNode, expectedOffset - ( modelOffset - lastLength ) );
  359. }
  360. }
  361. /**
  362. * Because we prefer positions in text nodes over positions next to text node moves view position to the text node
  363. * if it was next to it.
  364. *
  365. * <p>[]<b>foo</b></p> -> <p>[]<b>foo</b></p> // do not touch if position is not directly next to text
  366. * <p>foo[]<b>foo</b></p> -> <p>foo{}<b>foo</b></p> // move to text node
  367. * <p><b>[]foo</b></p> -> <p><b>{}foo</b></p> // move to text node
  368. *
  369. * @private
  370. * @param {module:engine/view/position~Position} viewPosition Position potentially next to text node.
  371. * @returns {module:engine/view/position~Position} Position in text node if possible.
  372. */
  373. _moveViewPositionToTextNode( viewPosition ) {
  374. // If the position is just after text node, put it at the end of that text node.
  375. // If the position is just before text node, put it at the beginning of that text node.
  376. const nodeBefore = viewPosition.nodeBefore;
  377. const nodeAfter = viewPosition.nodeAfter;
  378. if ( nodeBefore instanceof ViewText ) {
  379. return new ViewPosition( nodeBefore, nodeBefore.data.length );
  380. } else if ( nodeAfter instanceof ViewText ) {
  381. return new ViewPosition( nodeAfter, 0 );
  382. }
  383. // Otherwise, just return the given position.
  384. return viewPosition;
  385. }
  386. /**
  387. * Fired for each model-to-view position mapping request. The purpose of this event is to enable custom model-to-view position
  388. * mapping. Callbacks added to this event take {@link module:engine/model/position~Position model position} and are expected to calculate
  389. * {@link module:engine/view/position~Position view position}. Calculated view position should be added as `viewPosition` value in
  390. * `data` object that is passed as one of parameters to the event callback.
  391. *
  392. * // Assume that "captionedImage" model element is converted to <img> and following <span> elements in view,
  393. * // and the model element is bound to <img> element. Force mapping model positions inside "captionedImage" to that <span> element.
  394. * mapper.on( 'modelToViewPosition', ( evt, data ) => {
  395. * const positionParent = modelPosition.parent;
  396. *
  397. * if ( positionParent.name == 'captionedImage' ) {
  398. * const viewImg = data.mapper.toViewElement( positionParent );
  399. * const viewCaption = viewImg.nextSibling; // The <span> element.
  400. *
  401. * data.viewPosition = new ViewPosition( viewCaption, modelPosition.offset );
  402. *
  403. * // Stop the event if other callbacks should not modify calculated value.
  404. * evt.stop();
  405. * }
  406. * } );
  407. *
  408. * **Note:** keep in mind that custom callback provided for this event should use provided `data.modelPosition` only to check
  409. * what is before the position (or position's parent). This is important when model-to-view position mapping is used in
  410. * remove change conversion. Model after the removed position (that is being mapped) does not correspond to view, so it cannot be used:
  411. *
  412. * // Incorrect:
  413. * const modelElement = data.modelPosition.nodeAfter;
  414. * const viewElement = data.mapper.toViewElement( modelElement );
  415. * // ... Do something with `viewElement` and set `data.viewPosition`.
  416. *
  417. * // Correct:
  418. * const prevModelElement = data.modelPosition.nodeBefore;
  419. * const prevViewElement = data.mapper.toViewElement( prevModelElement );
  420. * // ... Use `prevViewElement` to find correct `data.viewPosition`.
  421. *
  422. * **Note:** default mapping callback is provided with `low` priority setting and does not cancel the event, so it is possible to attach
  423. * a custom callback after default callback and also use `data.viewPosition` calculated by default callback (for example to fix it).
  424. *
  425. * **Note:** default mapping callback will not fire if `data.viewPosition` is already set.
  426. *
  427. * **Note:** these callbacks are called **very often**. For efficiency reasons, it is advised to use them only when position
  428. * mapping between given model and view elements is unsolvable using just elements mapping and default algorithm. Also,
  429. * the condition that checks if special case scenario happened should be as simple as possible.
  430. *
  431. * @event modelToViewPosition
  432. * @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add
  433. * `viewPosition` value to that object with calculated {@link module:engine/view/position~Position view position}.
  434. * @param {module:engine/conversion/mapper~Mapper} data.mapper Mapper instance that fired the event.
  435. */
  436. /**
  437. * Fired for each view-to-model position mapping request. See {@link module:engine/conversion/mapper~Mapper#event:modelToViewPosition}.
  438. *
  439. * // See example in `modelToViewPosition` event description.
  440. * // This custom mapping will map positions from <span> element next to <img> to the "captionedImage" element.
  441. * mapper.on( 'viewToModelPosition', ( evt, data ) => {
  442. * const positionParent = viewPosition.parent;
  443. *
  444. * if ( positionParent.hasClass( 'image-caption' ) ) {
  445. * const viewImg = positionParent.previousSibling;
  446. * const modelImg = data.mapper.toModelElement( viewImg );
  447. *
  448. * data.modelPosition = new ModelPosition( modelImg, viewPosition.offset );
  449. * evt.stop();
  450. * }
  451. * } );
  452. *
  453. * **Note:** default mapping callback is provided with `low` priority setting and does not cancel the event, so it is possible to attach
  454. * a custom callback after default callback and also use `data.modelPosition` calculated by default callback (for example to fix it).
  455. *
  456. * **Note:** default mapping callback will not fire if `data.modelPosition` is already set.
  457. *
  458. * **Note:** these callbacks are called **very often**. For efficiency reasons, it is advised to use them only when position
  459. * mapping between given model and view elements is unsolvable using just elements mapping and default algorithm. Also,
  460. * the condition that checks if special case scenario happened should be as simple as possible.
  461. *
  462. * @event viewToModelPosition
  463. * @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add
  464. * `modelPosition` value to that object with calculated {@link module:engine/model/position~Position model position}.
  465. * @param {module:engine/conversion/mapper~Mapper} data.mapper Mapper instance that fired the event.
  466. */
  467. }
  468. mix( Mapper, EmitterMixin );