8
0

model-to-view-converters.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. /**
  2. * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. import ViewElement from '../view/element';
  6. import ViewAttributeElement from '../view/attributeelement';
  7. import ViewText from '../view/text';
  8. import ViewRange from '../view/range';
  9. import ViewPosition from '../view/position';
  10. import ViewTreeWalker from '../view/treewalker';
  11. import viewWriter from '../view/writer';
  12. import ModelRange from '../model/range';
  13. /**
  14. * Contains {@link module:engine/model/model model} to {@link module:engine/view/view view} converters for
  15. * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}.
  16. *
  17. * @module engine/conversion/model-to-view-converters
  18. */
  19. /**
  20. * Function factory, creates a converter that converts node insertion changes from the model to the view.
  21. * The view element that will be added to the view depends on passed parameter. If {@link module:engine/view/element~Element} was passed,
  22. * it will be cloned and the copy will be inserted. If `Function` is provided, it is passed all the parameters of the
  23. * dispatcher's {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:insert insert event}.
  24. * It's expected that the function returns a {@link module:engine/view/element~Element}.
  25. * The result of the function will be inserted to the view.
  26. *
  27. * The converter automatically consumes corresponding value from consumables list, stops the event (see
  28. * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}) and bind model and view elements.
  29. *
  30. * modelDispatcher.on( 'insert:paragraph', insertElement( new ViewElement( 'p' ) ) );
  31. *
  32. * modelDispatcher.on(
  33. * 'insert:myElem',
  34. * insertElement( ( data, consumable, conversionApi ) => {
  35. * let myElem = new ViewElement( 'myElem', { myAttr: true }, new ViewText( 'myText' ) );
  36. *
  37. * // Do something fancy with myElem using data/consumable/conversionApi ...
  38. *
  39. * return myElem;
  40. * }
  41. * ) );
  42. *
  43. * @param {module:engine/view/element~Element|Function} elementCreator View element, or function returning a view element, which
  44. * will be inserted.
  45. * @returns {Function} Insert element event converter.
  46. */
  47. export function insertElement( elementCreator ) {
  48. return ( evt, data, consumable, conversionApi ) => {
  49. const viewElement = ( elementCreator instanceof ViewElement ) ?
  50. elementCreator.clone( true ) :
  51. elementCreator( data, consumable, conversionApi );
  52. if ( !viewElement ) {
  53. return;
  54. }
  55. if ( !consumable.consume( data.item, 'insert' ) ) {
  56. return;
  57. }
  58. const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
  59. conversionApi.mapper.bindElements( data.item, viewElement );
  60. viewWriter.insert( viewPosition, viewElement );
  61. };
  62. }
  63. /**
  64. * Function factory, creates a default model-to-view converter for text insertion changes.
  65. *
  66. * The converter automatically consumes corresponding value from consumables list and stops the event (see
  67. * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
  68. *
  69. * modelDispatcher.on( 'insert:$text', insertText() );
  70. *
  71. * @returns {Function} Insert text event converter.
  72. */
  73. export function insertText() {
  74. return ( evt, data, consumable, conversionApi ) => {
  75. if ( !consumable.consume( data.item, 'insert' ) ) {
  76. return;
  77. }
  78. const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
  79. const viewText = new ViewText( data.item.data );
  80. viewWriter.insert( viewPosition, viewText );
  81. };
  82. }
  83. /**
  84. * Function factory, creates a converter that converts marker adding change to the view ui element.
  85. * The view ui element that will be added to the view depends on passed parameter. See {@link ~insertElement}.
  86. * In a case of collapsed range element will not wrap range but separate elements will be placed at the beginning
  87. * and at the end of the range.
  88. *
  89. * **Note:** unlike {@link ~insertElement}, the converter does not bind view element to model, because this converter
  90. * uses marker as "model source of data". This means that view ui element does not have corresponding model element.
  91. *
  92. * @param {module:engine/view/uielement~UIElement|Function} elementCreator View ui element, or function returning a view element, which
  93. * will be inserted.
  94. * @returns {Function} Insert element event converter.
  95. */
  96. export function insertUIElement( elementCreator ) {
  97. return ( evt, data, consumable, conversionApi ) => {
  98. let viewStartElement, viewEndElement;
  99. if ( elementCreator instanceof ViewElement ) {
  100. viewStartElement = elementCreator.clone( true );
  101. viewEndElement = elementCreator.clone( true );
  102. } else {
  103. data.isOpening = true;
  104. viewStartElement = elementCreator( data, consumable, conversionApi );
  105. data.isOpening = false;
  106. viewEndElement = elementCreator( data, consumable, conversionApi );
  107. }
  108. if ( !viewStartElement || !viewEndElement ) {
  109. return;
  110. }
  111. const markerRange = data.markerRange;
  112. const eventName = evt.name;
  113. // Marker that is collapsed has consumable build differently that non-collapsed one.
  114. // For more information see `addMarker` and `removeMarker` events description.
  115. // If marker's range is collapsed - check if it can be consumed.
  116. if ( markerRange.isCollapsed && !consumable.consume( markerRange, eventName ) ) {
  117. return;
  118. }
  119. // If marker's range is not collapsed - consume all items inside.
  120. for ( const value of markerRange ) {
  121. if ( !consumable.consume( value.item, eventName ) ) {
  122. return;
  123. }
  124. }
  125. const mapper = conversionApi.mapper;
  126. viewWriter.insert( mapper.toViewPosition( markerRange.start ), viewStartElement );
  127. if ( !markerRange.isCollapsed ) {
  128. viewWriter.insert( mapper.toViewPosition( markerRange.end ), viewEndElement );
  129. }
  130. };
  131. }
  132. /**
  133. * Function factory, creates a converter that converts set/change attribute changes from the model to the view. Attributes
  134. * from model are converted to the view element attributes in the view. You may provide a custom function to generate a
  135. * key-value attribute pair to add/change. If not provided, model attributes will be converted to view elements attributes
  136. * on 1-to-1 basis.
  137. *
  138. * **Note:** Provided attribute creator should always return the same `key` for given attribute from the model.
  139. *
  140. * The converter automatically consumes corresponding value from consumables list and stops the event (see
  141. * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
  142. *
  143. * modelDispatcher.on( 'addAttribute:customAttr:myElem', setAttribute( ( data ) => {
  144. * // Change attribute key from `customAttr` to `class` in view.
  145. * const key = 'class';
  146. * let value = data.attributeNewValue;
  147. *
  148. * // Force attribute value to 'empty' if the model element is empty.
  149. * if ( data.item.childCount === 0 ) {
  150. * value = 'empty';
  151. * }
  152. *
  153. * // Return key-value pair.
  154. * return { key, value };
  155. * } ) );
  156. *
  157. * @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
  158. * represents attribute key and attribute value to be set on a {@link module:engine/view/element~Element view element}.
  159. * The function is passed all the parameters of the
  160. * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute}
  161. * or {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:changeAttribute} event.
  162. * @returns {Function} Set/change attribute converter.
  163. */
  164. export function setAttribute( attributeCreator ) {
  165. attributeCreator = attributeCreator || ( ( value, key ) => ( { value, key } ) );
  166. return ( evt, data, consumable, conversionApi ) => {
  167. if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
  168. return;
  169. }
  170. const { key, value } = attributeCreator( data.attributeNewValue, data.attributeKey, data, consumable, conversionApi );
  171. conversionApi.mapper.toViewElement( data.item ).setAttribute( key, value );
  172. };
  173. }
  174. /**
  175. * Function factory, creates a converter that converts remove attribute changes from the model to the view. Removes attributes
  176. * that were converted to the view element attributes in the view. You may provide a custom function to generate a
  177. * key-value attribute pair to remove. If not provided, model attributes will be removed from view elements on 1-to-1 basis.
  178. *
  179. * **Note:** Provided attribute creator should always return the same `key` for given attribute from the model.
  180. *
  181. * **Note:** You can use the same attribute creator as in {@link module:engine/conversion/model-to-view-converters~setAttribute}.
  182. *
  183. * The converter automatically consumes corresponding value from consumables list and stops the event (see
  184. * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
  185. *
  186. * modelDispatcher.on( 'removeAttribute:customAttr:myElem', removeAttribute( ( data ) => {
  187. * // Change attribute key from `customAttr` to `class` in view.
  188. * const key = 'class';
  189. * let value = data.attributeNewValue;
  190. *
  191. * // Force attribute value to 'empty' if the model element is empty.
  192. * if ( data.item.childCount === 0 ) {
  193. * value = 'empty';
  194. * }
  195. *
  196. * // Return key-value pair.
  197. * return { key, value };
  198. * } ) );
  199. *
  200. * @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
  201. * represents attribute key and attribute value to be removed from {@link module:engine/view/element~Element view element}.
  202. * The function is passed all the parameters of the
  203. * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute addAttribute event}
  204. * or {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:changeAttribute changeAttribute event}.
  205. * @returns {Function} Remove attribute converter.
  206. */
  207. export function removeAttribute( attributeCreator ) {
  208. attributeCreator = attributeCreator || ( ( value, key ) => ( { key } ) );
  209. return ( evt, data, consumable, conversionApi ) => {
  210. if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
  211. return;
  212. }
  213. const { key } = attributeCreator( data.attributeOldValue, data.attributeKey, data, consumable, conversionApi );
  214. conversionApi.mapper.toViewElement( data.item ).removeAttribute( key );
  215. };
  216. }
  217. /**
  218. * Function factory, creates a converter that converts set/change attribute changes from the model to the view. In this case,
  219. * model attributes are converted to a view element that will be wrapping view nodes which corresponding model nodes had
  220. * the attribute set. This is useful for attributes like `bold`, which may be set on text nodes in model but are
  221. * represented as an element in the view:
  222. *
  223. * [paragraph] MODEL ====> VIEW <p>
  224. * |- a {bold: true} |- <b>
  225. * |- b {bold: true} | |- ab
  226. * |- c |- c
  227. *
  228. * The wrapping node depends on passed parameter. If {@link module:engine/view/element~Element} was passed, it will be cloned and
  229. * the copy will become the wrapping element. If `Function` is provided, it is passed attribute value and then all the parameters of the
  230. * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute addAttribute event}.
  231. * It's expected that the function returns a {@link module:engine/view/element~Element}.
  232. * The result of the function will be the wrapping element.
  233. * When provided `Function` does not return element, then will be no conversion.
  234. *
  235. * The converter automatically consumes corresponding value from consumables list, stops the event (see
  236. * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
  237. *
  238. * modelDispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
  239. *
  240. * @param {module:engine/view/element~Element|Function} elementCreator View element, or function returning a view element, which will
  241. * be used for wrapping.
  242. * @returns {Function} Set/change attribute converter.
  243. */
  244. export function wrapItem( elementCreator ) {
  245. return ( evt, data, consumable, conversionApi ) => {
  246. const viewElement = ( elementCreator instanceof ViewElement ) ?
  247. elementCreator.clone( true ) :
  248. elementCreator( data.attributeNewValue, data, consumable, conversionApi );
  249. if ( !viewElement ) {
  250. return;
  251. }
  252. if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
  253. return;
  254. }
  255. let viewRange = conversionApi.mapper.toViewRange( data.range );
  256. // If this is a change event (because old value is not empty) and the creator is a function (so
  257. // it may create different view elements basing on attribute value) we have to create
  258. // view element basing on old value and unwrap it before wrapping with a newly created view element.
  259. if ( data.attributeOldValue !== null && !( elementCreator instanceof ViewElement ) ) {
  260. const oldViewElement = elementCreator( data.attributeOldValue, data, consumable, conversionApi );
  261. viewRange = viewWriter.unwrap( viewRange, oldViewElement );
  262. }
  263. viewWriter.wrap( viewRange, viewElement );
  264. };
  265. }
  266. /**
  267. * Function factory, creates a converter that converts remove attribute changes from the model to the view. It assumes, that
  268. * attributes from model were converted to elements in the view. This converter will unwrap view nodes from corresponding
  269. * view element if given attribute was removed.
  270. *
  271. * The view element type that will be unwrapped depends on passed parameter.
  272. * If {@link module:engine/view/element~Element} was passed, it will be used to look for similar element in the view for unwrapping.
  273. * If `Function` is provided, it is passed all the parameters of the
  274. * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute addAttribute event}.
  275. * It's expected that the function returns a {@link module:engine/view/element~Element}.
  276. * The result of the function will be used to look for similar element in the view for unwrapping.
  277. *
  278. * The converter automatically consumes corresponding value from consumables list, stops the event (see
  279. * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}) and bind model and view elements.
  280. *
  281. * modelDispatcher.on( 'removeAttribute:bold', unwrapItem( new ViewAttributeElement( 'strong' ) ) );
  282. *
  283. * @see module:engine/conversion/model-to-view-converters~wrapItem
  284. * @param {module:engine/view/element~Element|Function} elementCreator View element, or function returning a view element, which will
  285. * be used for unwrapping.
  286. * @returns {Function} Remove attribute converter.
  287. */
  288. export function unwrapItem( elementCreator ) {
  289. return ( evt, data, consumable, conversionApi ) => {
  290. const viewElement = ( elementCreator instanceof ViewElement ) ?
  291. elementCreator.clone( true ) :
  292. elementCreator( data.attributeOldValue, data, consumable, conversionApi );
  293. if ( !viewElement ) {
  294. return;
  295. }
  296. if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
  297. return;
  298. }
  299. const viewRange = conversionApi.mapper.toViewRange( data.range );
  300. viewWriter.unwrap( viewRange, viewElement );
  301. };
  302. }
  303. /**
  304. * Function factory, creates a default model-to-view converter for node remove changes.
  305. *
  306. * modelDispatcher.on( 'remove', remove() );
  307. *
  308. * @returns {Function} Remove event converter.
  309. */
  310. export function remove() {
  311. return ( evt, data, consumable, conversionApi ) => {
  312. if ( !consumable.consume( data.item, 'remove' ) ) {
  313. return;
  314. }
  315. // We cannot map non-existing positions from model to view. Since a range was removed
  316. // from the model, we cannot recreate that range and map it to view, because
  317. // end of that range is incorrect.
  318. // Instead we will use `data.sourcePosition` as this is the last correct model position and
  319. // it is a position before the removed item. Then, we will calculate view range to remove "manually".
  320. let viewPosition = conversionApi.mapper.toViewPosition( data.sourcePosition );
  321. let viewRange;
  322. if ( data.item.is( 'element' ) ) {
  323. // Note: in remove conversion we cannot use model-to-view element mapping because `data.item` may be
  324. // already mapped to another element (this happens when move change is converted).
  325. // In this case however, `viewPosition` is the position before view element that corresponds to removed model element.
  326. //
  327. // First, fix the position. Traverse the tree forward until the container element is found. The `viewPosition`
  328. // may be before a ui element, before attribute element or at the end of text element.
  329. viewPosition = viewPosition.getLastMatchingPosition( value => !value.item.is( 'containerElement' ) );
  330. if ( viewPosition.parent.is( 'text' ) && viewPosition.isAtEnd ) {
  331. viewPosition = ViewPosition.createAfter( viewPosition.parent );
  332. }
  333. viewRange = ViewRange.createOn( viewPosition.nodeAfter );
  334. } else {
  335. // If removed item is a text node, we need to traverse view tree to find the view range to remove.
  336. // Range to remove will start `viewPosition` and should contain amount of characters equal to the amount of removed characters.
  337. const viewRangeEnd = _shiftViewPositionByCharacters( viewPosition, data.item.offsetSize );
  338. viewRange = new ViewRange( viewPosition, viewRangeEnd );
  339. }
  340. // Trim the range to remove in case some UI elements are on the view range boundaries.
  341. viewWriter.remove( viewRange.getTrimmed() );
  342. // Unbind this element only if it was moved to graveyard.
  343. // The dispatcher#remove event will also be fired if the element was moved to another place (remove+insert are fired).
  344. // Let's say that <b> is moved before <a>. The view will be changed like this:
  345. //
  346. // 1) start: <a></a><b></b>
  347. // 2) insert: <b (new)></b><a></a><b></b>
  348. // 3) remove: <b (new)></b><a></a>
  349. //
  350. // If we'll unbind the <b> element in step 3 we'll also lose binding of the <b (new)> element in the view,
  351. // because unbindModelElement() cancels both bindings – (model <b> => view <b (new)>) and (view <b (new)> => model <b>).
  352. // We can't lose any of these.
  353. //
  354. // See #847.
  355. if ( data.item.root.rootName == '$graveyard' ) {
  356. conversionApi.mapper.unbindModelElement( data.item );
  357. }
  358. };
  359. }
  360. /**
  361. * Function factory, creates converter that converts all texts inside marker's range. Converter wraps each text with
  362. * {@link module:engine/view/attributeelement~AttributeElement} created from provided descriptor.
  363. * See {link module:engine/conversion/model-to-view-converters~highlightDescriptorToAttributeElement}.
  364. *
  365. * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor
  366. * @return {Function}
  367. */
  368. export function highlightText( highlightDescriptor ) {
  369. return ( evt, data, consumable, conversionApi ) => {
  370. const descriptor = typeof highlightDescriptor == 'function' ?
  371. highlightDescriptor( data, consumable, conversionApi ) :
  372. highlightDescriptor;
  373. const modelItem = data.item;
  374. if ( !descriptor || data.markerRange.isCollapsed || !modelItem.is( 'textProxy' ) ) {
  375. return;
  376. }
  377. if ( !consumable.consume( modelItem, evt.name ) ) {
  378. return;
  379. }
  380. if ( !descriptor.id ) {
  381. descriptor.id = data.markerName;
  382. }
  383. const viewElement = createViewElementFromHighlightDescriptor( descriptor );
  384. const viewRange = conversionApi.mapper.toViewRange( data.range );
  385. if ( evt.name.split( ':' )[ 0 ] == 'addMarker' ) {
  386. viewWriter.wrap( viewRange, viewElement );
  387. } else {
  388. viewWriter.unwrap( viewRange, viewElement );
  389. }
  390. };
  391. }
  392. /**
  393. * Function factory, creates converter that converts all elements inside marker's range. Converter checks if element has
  394. * functions stored under `addHighlight` and `removeHighlight` custom properties and calls them passing
  395. * {@link module:engine/conversion/model-to-view-converters~HighlightDescriptor}. In such case converter will consume
  396. * all element's children, assuming that they were handled by element itself. If highlight descriptor will not provide
  397. * priority, priority `10` will be used as default, to be compliant with
  398. * {@link module:engine/conversion/model-to-view-converters~highlightText} method which uses default priority of
  399. * {@link module:engine/view/attributeelement~AttributeElement}.
  400. *
  401. * If highlight descriptor will not provide `id` property, name of the marker will be used.
  402. * When `addHighlight` and `removeHighlight` custom properties are not present, element is not converted
  403. * in any special way. This means that converters will proceed to convert element's child nodes.
  404. *
  405. * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor
  406. * @return {Function}
  407. */
  408. export function highlightElement( highlightDescriptor ) {
  409. return ( evt, data, consumable, conversionApi ) => {
  410. const descriptor = typeof highlightDescriptor == 'function' ?
  411. highlightDescriptor( data, consumable, conversionApi ) :
  412. highlightDescriptor;
  413. const modelItem = data.item;
  414. if ( !descriptor || data.markerRange.isCollapsed || !modelItem.is( 'element' ) ) {
  415. return;
  416. }
  417. if ( !consumable.test( data.item, evt.name ) ) {
  418. return;
  419. }
  420. if ( !descriptor.priority ) {
  421. descriptor.priority = 10;
  422. }
  423. if ( !descriptor.id ) {
  424. descriptor.id = data.markerName;
  425. }
  426. const viewElement = conversionApi.mapper.toViewElement( modelItem );
  427. const addMarker = evt.name.split( ':' )[ 0 ] == 'addMarker';
  428. const highlightHandlingMethod = addMarker ? 'addHighlight' : 'removeHighlight';
  429. if ( viewElement && viewElement.getCustomProperty( highlightHandlingMethod ) ) {
  430. // Consume element itself.
  431. consumable.consume( data.item, evt.name );
  432. // Consume all children nodes.
  433. for ( const value of ModelRange.createIn( modelItem ) ) {
  434. consumable.consume( value.item, evt.name );
  435. }
  436. viewElement.getCustomProperty( highlightHandlingMethod )( viewElement, descriptor );
  437. }
  438. };
  439. }
  440. /**
  441. * Function factory, creates a default model-to-view converter for removing {@link module:engine/view/uielement~UIElement ui element}
  442. * basing on marker remove change.
  443. *
  444. * @param {module:engine/view/uielement~UIElement|Function} elementCreator View ui element, or function returning
  445. * a view ui element, which will be used as a pattern when look for element to remove at the marker start position.
  446. * @returns {Function} Remove ui element converter.
  447. */
  448. export function removeUIElement( elementCreator ) {
  449. return ( evt, data, consumable, conversionApi ) => {
  450. let viewStartElement, viewEndElement;
  451. if ( elementCreator instanceof ViewElement ) {
  452. viewStartElement = elementCreator.clone( true );
  453. viewEndElement = elementCreator.clone( true );
  454. } else {
  455. data.isOpening = true;
  456. viewStartElement = elementCreator( data, consumable, conversionApi );
  457. data.isOpening = false;
  458. viewEndElement = elementCreator( data, consumable, conversionApi );
  459. }
  460. if ( !viewStartElement || !viewEndElement ) {
  461. return;
  462. }
  463. const markerRange = data.markerRange;
  464. const eventName = evt.name;
  465. // If marker's range is collapsed - check if it can be consumed.
  466. if ( markerRange.isCollapsed && !consumable.consume( markerRange, eventName ) ) {
  467. return;
  468. }
  469. // Check if all items in the range can be consumed, and consume them.
  470. for ( const value of markerRange ) {
  471. if ( !consumable.consume( value.item, eventName ) ) {
  472. return;
  473. }
  474. }
  475. const viewRange = conversionApi.mapper.toViewRange( markerRange );
  476. // First remove closing element.
  477. viewWriter.clear( viewRange.getEnlarged(), viewEndElement );
  478. // If closing and opening elements are not the same then remove opening element.
  479. if ( !viewStartElement.isSimilar( viewEndElement ) ) {
  480. viewWriter.clear( viewRange.getEnlarged(), viewStartElement );
  481. }
  482. };
  483. }
  484. /**
  485. * Returns the consumable type that is to be consumed in an event, basing on that event name.
  486. *
  487. * @param {String} evtName Event name.
  488. * @returns {String} Consumable type.
  489. */
  490. export function eventNameToConsumableType( evtName ) {
  491. const parts = evtName.split( ':' );
  492. return parts[ 0 ] + ':' + parts[ 1 ];
  493. }
  494. // Helper function that shifts given view `position` in a way that returned position is after `howMany` characters compared
  495. // to the original `position`.
  496. // Because in view there might be view ui elements splitting text nodes, we cannot simply use `ViewPosition#getShiftedBy()`.
  497. function _shiftViewPositionByCharacters( position, howMany ) {
  498. // Create a walker that will walk the view tree starting from given position and walking characters one-by-one.
  499. const walker = new ViewTreeWalker( { startPosition: position, singleCharacters: true } );
  500. // We will count visited characters and return the position after `howMany` characters.
  501. let charactersFound = 0;
  502. for ( const value of walker ) {
  503. if ( value.type == 'text' ) {
  504. charactersFound++;
  505. if ( charactersFound == howMany ) {
  506. return walker.position;
  507. }
  508. }
  509. }
  510. }
  511. /**
  512. * Creates `span` {@link module:engine/view/attributeelement~AttributeElement view attribute element} from information
  513. * provided by {@link module:engine/conversion/model-to-view-converters~HighlightDescriptor} object. If priority
  514. * is not provided in descriptor - default priority will be used.
  515. *
  516. * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor} descriptor
  517. * @return {module:engine/conversion/model-to-view-converters~HighlightAttributeElement}
  518. */
  519. export function createViewElementFromHighlightDescriptor( descriptor ) {
  520. const viewElement = new HighlightAttributeElement( 'span', descriptor.attributes );
  521. if ( descriptor.class ) {
  522. const cssClasses = Array.isArray( descriptor.class ) ? descriptor.class : [ descriptor.class ];
  523. viewElement.addClass( ...cssClasses );
  524. }
  525. if ( descriptor.priority ) {
  526. viewElement.priority = descriptor.priority;
  527. }
  528. viewElement.setCustomProperty( 'highlightDescriptorId', descriptor.id );
  529. return viewElement;
  530. }
  531. /**
  532. * Special kind of {@link module:engine/view/attributeelement~AttributeElement} that is created and used in
  533. * marker-to-highlight conversion.
  534. *
  535. * The difference between `HighlightAttributeElement` and {@link module:engine/view/attributeelement~AttributeElement}
  536. * is {@link module:engine/view/attributeelement~AttributeElement#isSimilar} method.
  537. *
  538. * For `HighlightAttributeElement` it checks just `highlightDescriptorId` custom property, that is set during marker-to-highlight
  539. * conversion basing on {@link module:engine/conversion/model-to-view-converters~HighlightDescriptor} object.
  540. * `HighlightAttributeElement`s with same `highlightDescriptorId` property are considered similar.
  541. */
  542. class HighlightAttributeElement extends ViewAttributeElement {
  543. isSimilar( otherElement ) {
  544. if ( otherElement.is( 'attributeElement' ) ) {
  545. return this.getCustomProperty( 'highlightDescriptorId' ) === otherElement.getCustomProperty( 'highlightDescriptorId' );
  546. }
  547. return false;
  548. }
  549. }
  550. /**
  551. * Object describing how content highlight should be created in the view.
  552. *
  553. * Each text node contained in highlight will be wrapped with `span` element with CSS class(es), attributes and priority
  554. * described by this object.
  555. *
  556. * Each element can handle displaying highlight separately by providing `addHighlight` and `removeHighlight` custom
  557. * properties. Those properties are passed `HighlightDescriptor` object upon conversion and should use it to
  558. * change the element.
  559. *
  560. * @typedef {Object} module:engine/conversion/model-to-view-converters~HighlightDescriptor
  561. *
  562. * @property {String|Array.<String>} class CSS class or array of classes to set. If descriptor is used to
  563. * create {@link module:engine/view/attributeelement~AttributeElement} over text nodes, those classes will be set
  564. * on that {@link module:engine/view/attributeelement~AttributeElement}. If descriptor is applied to an element,
  565. * usually those class will be set on that element, however this depends on how the element converts the descriptor.
  566. *
  567. * @property {String} [id] Descriptor identifier. If not provided, defaults to converted marker's name.
  568. *
  569. * @property {Number} [priority] Descriptor priority. If not provided, defaults to `10`. If descriptor is used to create
  570. * {@link module:engine/view/attributeelement~AttributeElement}, it will be that element's
  571. * {@link module:engine/view/attributeelement~AttributeElement#priority}. If descriptor is applied to an element,
  572. * the priority will be used to determine which descriptor is more important.
  573. *
  574. * @property {Object} [attributes] Attributes to set. If descriptor is used to create
  575. * {@link module:engine/view/attributeelement~AttributeElement} over text nodes, those attributes will be set on that
  576. * {@link module:engine/view/attributeelement~AttributeElement}. If descriptor is applied to an element, usually those
  577. * attributes will be set on that element, however this depends on how the element converts the descriptor.
  578. */