downcast-converters.js 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038
  1. /**
  2. * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. import ModelRange from '../model/range';
  6. import ModelSelection from '../model/selection';
  7. import ModelElement from '../model/element';
  8. import ViewAttributeElement from '../view/attributeelement';
  9. import ViewRange from '../view/range';
  10. import DocumentSelection from '../model/documentselection';
  11. import cloneDeep from '@ckeditor/ckeditor5-utils/src/lib/lodash/cloneDeep';
  12. /**
  13. * Contains downcast (model to view) converters for {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}.
  14. *
  15. * @module engine/conversion/downcast-converters
  16. */
  17. /**
  18. * Model element to view element conversion helper.
  19. *
  20. * This conversion results in creating a view element. For example, model `<paragraph>Foo</paragraph>` becomes `<p>Foo</p>` in the view.
  21. *
  22. * downcastElementToElement( { model: 'paragraph', view: 'p' } );
  23. *
  24. * downcastElementToElement( { model: 'paragraph', view: 'div', priority: 'high' } );
  25. *
  26. * downcastElementToElement( {
  27. * model: 'fancyParagraph',
  28. * view: {
  29. * name: 'p',
  30. * class: 'fancy'
  31. * }
  32. * } );
  33. *
  34. * downcastElementToElement( {
  35. * model: 'heading',
  36. * view: ( modelElement, viewWriter ) => viewWriter.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) )
  37. * } );
  38. *
  39. * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
  40. *
  41. * @param {Object} config Conversion configuration.
  42. * @param {String} config.model Name of the model element to convert.
  43. * @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view View element definition or a function
  44. * that takes model element and view writer as a parameters and returns a view container element.
  45. * @returns {Function} Conversion helper.
  46. */
  47. export function downcastElementToElement( config ) {
  48. config = cloneDeep( config );
  49. config.view = _normalizeToElementConfig( config.view, 'container' );
  50. return dispatcher => {
  51. dispatcher.on( 'insert:' + config.model, insertElement( config.view ), { priority: config.priority || 'normal' } );
  52. };
  53. }
  54. /**
  55. * Model attribute to view element conversion helper.
  56. *
  57. * This conversion results in wrapping view nodes in a view attribute element. For example, model text node with data
  58. * `"Foo"` and `bold` attribute becomes `<strong>Foo</strong>` in the view.
  59. *
  60. * downcastAttributeToElement( { model: 'bold', view: 'strong' } );
  61. *
  62. * downcastAttributeToElement( { model: 'bold', view: 'b', priority: 'high' } );
  63. *
  64. * downcastAttributeToElement( {
  65. * model: 'invert',
  66. * view: {
  67. * name: 'span',
  68. * class: [ 'font-light', 'bg-dark' ]
  69. * }
  70. * } );
  71. *
  72. * downcastAttributeToElement( {
  73. * model: {
  74. * key: 'fontSize',
  75. * values: [ 'big', 'small' ]
  76. * },
  77. * view: {
  78. * big: {
  79. * name: 'span',
  80. * style: {
  81. * 'font-size': '1.2em'
  82. * }
  83. * },
  84. * small: {
  85. * name: 'span',
  86. * style: {
  87. * 'font-size': '0.8em'
  88. * }
  89. * }
  90. * }
  91. * } );
  92. *
  93. * downcastAttributeToElement( {
  94. * model: 'bold',
  95. * view: ( modelAttributeValue, viewWriter ) => {
  96. * return viewWriter.createAttributeElement( 'span', { style: 'font-weight:' + modelAttributeValue } );
  97. * }
  98. * } );
  99. *
  100. * downcastAttributeToElement( {
  101. * model: {
  102. * key: 'color',
  103. * name: '$text'
  104. * },
  105. * view: ( modelAttributeValue, viewWriter ) => {
  106. * return viewWriter.createAttributeElement( 'span', { style: 'color:' + modelAttributeValue } );
  107. * }
  108. * } );
  109. *
  110. * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
  111. *
  112. * @param {Object} config Conversion configuration.
  113. * @param {String|Object} config.model Key of the attribute to convert from or a `{ key, values }` object. `values` is an array
  114. * of `String`s with possible values if the model attribute is enumerable.
  115. * @param {module:engine/view/elementdefinition~ElementDefinition|Function|Object} config.view View element definition or a function
  116. * that takes model attribute value and view writer as parameters and returns a view attribute element. If `config.model.values` is
  117. * given, `config.view` should be an object assigning values from `config.model.values` to view element definitions or functions.
  118. * @param {module:utils/priorities~PriorityString} [config.priority='normal'] Converter priority.
  119. * @returns {Function} Conversion helper.
  120. */
  121. export function downcastAttributeToElement( config ) {
  122. config = cloneDeep( config );
  123. const modelKey = config.model.key ? config.model.key : config.model;
  124. let eventName = 'attribute:' + modelKey;
  125. if ( config.model.name ) {
  126. eventName += ':' + config.model.name;
  127. }
  128. if ( config.model.values ) {
  129. for ( const modelValue of config.model.values ) {
  130. config.view[ modelValue ] = _normalizeToElementConfig( config.view[ modelValue ], 'attribute' );
  131. }
  132. } else {
  133. config.view = _normalizeToElementConfig( config.view, 'attribute' );
  134. }
  135. const elementCreator = _getFromAttributeCreator( config );
  136. return dispatcher => {
  137. dispatcher.on( eventName, wrap( elementCreator ), { priority: config.priority || 'normal' } );
  138. };
  139. }
  140. /**
  141. * Model attribute to view attribute conversion helper.
  142. *
  143. * This conversion results in adding an attribute on a view node, basing on an attribute from a model node. For example,
  144. * `<image src='foo.jpg'></image>` is converted to `<img src='foo.jpg'></img>`.
  145. *
  146. * downcastAttributeToAttribute( { model: 'source', view: 'src' } );
  147. *
  148. * downcastAttributeToAttribute( { model: 'source', view: 'href', priority: 'high' } );
  149. *
  150. * downcastAttributeToAttribute( {
  151. * model: {
  152. * name: 'image',
  153. * key: 'source'
  154. * },
  155. * view: 'src'
  156. * } );
  157. *
  158. * downcastAttributeToAttribute( {
  159. * model: {
  160. * name: 'styled',
  161. * values: [ 'dark', 'light' ]
  162. * },
  163. * view: {
  164. * dark: {
  165. * key: 'class',
  166. * value: [ 'styled', 'styled-dark' ]
  167. * },
  168. * light: {
  169. * key: 'class',
  170. * value: [ 'styled', 'styled-light' ]
  171. * }
  172. * }
  173. * } );
  174. *
  175. * downcastAttributeToAttribute( {
  176. * model: 'styled',
  177. * view: modelAttributeValue => ( { key: 'class', value: 'styled-' + modelAttributeValue } )
  178. * } );
  179. *
  180. * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
  181. *
  182. * @param {Object} config Conversion configuration.
  183. * @param {String|Object} config.model Key of the attribute to convert from or a `{ key, values, [ name ] }` object describing
  184. * the attribute key, possible values and, optionally, an element name to convert from.
  185. * @param {String|Object|Function} config.view View attribute key, or a `{ key, value }` object or a function that takes
  186. * model attribute value and returns a `{ key, value }` object. If `key` is `'class'`, `value` can be a `String` or an
  187. * array of `String`s. If `key` is `'style'`, `value` is an object with key-value pairs. In other cases, `value` is a `String`.
  188. * If `config.model.values` is set, `config.view` should be an object assigning values from `config.model.values` to
  189. * `{ key, value }` objects or a functions.
  190. * @param {module:utils/priorities~PriorityString} [config.priority='normal'] Converter priority.
  191. * @returns {Function} Conversion helper.
  192. */
  193. export function downcastAttributeToAttribute( config ) {
  194. config = cloneDeep( config );
  195. const modelKey = config.model.key ? config.model.key : config.model;
  196. let eventName = 'attribute:' + modelKey;
  197. if ( config.model.name ) {
  198. eventName += ':' + config.model.name;
  199. }
  200. if ( config.model.values ) {
  201. for ( const modelValue of config.model.values ) {
  202. config.view[ modelValue ] = _normalizeToAttributeConfig( config.view[ modelValue ] );
  203. }
  204. } else {
  205. config.view = _normalizeToAttributeConfig( config.view );
  206. }
  207. const elementCreator = _getFromAttributeCreator( config );
  208. return dispatcher => {
  209. dispatcher.on( eventName, changeAttribute( elementCreator ), { priority: config.priority || 'normal' } );
  210. };
  211. }
  212. /**
  213. * Model marker to view element conversion helper.
  214. *
  215. * This conversion results in creating a view element on the boundaries of the converted marker. If converted marker
  216. * is collapsed, only one element is created. For example, model marker set like this `<paragraph>F[oo b]ar</paragraph>`
  217. * becomes `<p>F<span data-marker="search"></span>oo b<span data-marker="search"></span>ar</p>` in the view.
  218. *
  219. * downcastMarkerToElement( { model: 'search', view: 'marker-search' } );
  220. *
  221. * downcastMarkerToElement( { model: 'search', view: 'search-result', priority: 'high' } );
  222. *
  223. * downcastMarkerToElement( {
  224. * model: 'search',
  225. * view: {
  226. * name: 'span',
  227. * attribute: {
  228. * 'data-marker': 'search'
  229. * }
  230. * }
  231. * } );
  232. *
  233. * downcastMarkerToElement( {
  234. * model: 'search',
  235. * view: ( markerData, viewWriter ) => {
  236. * return viewWriter.createUIElement( 'span', { 'data-marker': 'search', 'data-start': markerData.isOpening } );
  237. * }
  238. * } );
  239. *
  240. * If function is passed as `config.view` parameter, it will be used to generate both boundary elements. The function
  241. * receives `data` object as parameter and should return an instance of {@link module:engine/view/uielement~UIElement view.UIElement}.
  242. * The `data` and `conversionApi` objects are passed from
  243. * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker}. Additionally,
  244. * `data.isOpening` parameter is passed, which is set to `true` for marker start boundary element, and `false` to
  245. * marker end boundary element.
  246. *
  247. * This kind of conversion is useful for saving data into data base, so it should be used in data conversion pipeline.
  248. *
  249. * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
  250. *
  251. * @param {Object} config Conversion configuration.
  252. * @param {String} config.model Name of the model marker (or model marker group) to convert.
  253. * @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view View element definition or a function
  254. * that takes model marker data as a parameter and returns view ui element.
  255. * @param {module:utils/priorities~PriorityString} [config.priority='normal'] Converter priority.
  256. * @returns {Function} Conversion helper.
  257. */
  258. export function downcastMarkerToElement( config ) {
  259. config = cloneDeep( config );
  260. config.view = _normalizeToElementConfig( config.view, 'ui' );
  261. return dispatcher => {
  262. dispatcher.on( 'addMarker:' + config.model, insertUIElement( config.view ), { priority: config.priority || 'normal' } );
  263. dispatcher.on( 'removeMarker:' + config.model, removeUIElement( config.view ), { priority: config.priority || 'normal' } );
  264. };
  265. }
  266. /**
  267. * Model marker to highlight conversion helper.
  268. *
  269. * This conversion results in creating a highlight on view nodes. For this kind of conversion,
  270. * {@link module:engine/conversion/downcast-converters~HighlightDescriptor} should be provided.
  271. *
  272. * For text nodes, a `span` {@link module:engine/view/attributeelement~AttributeElement} is created and it wraps all text nodes
  273. * in the converted marker range. For example, model marker set like this `<paragraph>F[oo b]ar</paragraph>` becomes
  274. * `<p>F<span class="comment">oo b</span>ar</p>` in the view.
  275. *
  276. * {@link module:engine/view/containerelement~ContainerElement} may provide custom way of handling highlight. Most often,
  277. * the element itself is given classes and attributes described in the highlight descriptor (instead of being wrapped in `span`).
  278. * For example, model marker set like this `[<image src="foo.jpg"></image>]` becomes `<img src="foo.jpg" class="comment"></img>`
  279. * in the view.
  280. *
  281. * For container elements, the conversion is two-step. While the converter processes highlight descriptor and passes it
  282. * to a container element, it is the container element instance itself which applies values from highlight descriptor.
  283. * So, in a sense, converter takes care of stating what should be applied on what, while element decides how to apply that.
  284. *
  285. * downcastMarkerToHighlight( { model: 'comment', view: { class: 'comment' } } );
  286. *
  287. * downcastMarkerToHighlight( { model: 'comment', view: { class: 'new-comment' }, priority: 'high' } );
  288. *
  289. * downcastMarkerToHighlight( {
  290. * model: 'comment',
  291. * view: data => {
  292. * // Assuming that marker name is in a form of comment:commentType.
  293. * const commentType = data.markerName.split( ':' )[ 1 ];
  294. *
  295. * return {
  296. * class: [ 'comment', 'comment-' + commentType ]
  297. * };
  298. * }
  299. * } );
  300. *
  301. * If function is passed as `config.view` parameter, it will be used to generate highlight descriptor. The function
  302. * receives `data` object as parameter and should return a {@link module:engine/conversion/downcast-converters~HighlightDescriptor}.
  303. * The `data` object properties are passed from {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker}.
  304. *
  305. * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
  306. *
  307. * @param {Object} config Conversion configuration.
  308. * @param {String} config.model Name of the model marker (or model marker group) to convert.
  309. * @param {module:engine/conversion/downcast-converters~HighlightDescriptor|Function} config.view Highlight descriptor
  310. * which will be used for highlighting or a function that takes model marker data as a parameter and returns a highlight descriptor.
  311. * @param {module:utils/priorities~PriorityString} [config.priority='normal'] Converter priority.
  312. * @returns {Function} Conversion helper.
  313. */
  314. export function downcastMarkerToHighlight( config ) {
  315. return dispatcher => {
  316. dispatcher.on( 'addMarker:' + config.model, highlightText( config.view ), { priority: config.priority || 'normal' } );
  317. dispatcher.on( 'addMarker:' + config.model, highlightElement( config.view ), { priority: config.priority || 'normal' } );
  318. dispatcher.on( 'removeMarker:' + config.model, removeHighlight( config.view ), { priority: config.priority || 'normal' } );
  319. };
  320. }
  321. // Takes `config.view`, and if it is a {@link module:engine/view/elementdefinition~ElementDefinition}, converts it
  322. // to a function (because lower level converters accepts only element creator functions).
  323. //
  324. // @param {module:engine/view/elementdefinition~ElementDefinition|Function} view View configuration.
  325. // @param {'container'|'attribute'|'ui'} viewElementType View element type to create.
  326. // @returns {Function} Element creator function to use in lower level converters.
  327. function _normalizeToElementConfig( view, viewElementType ) {
  328. if ( typeof view == 'function' ) {
  329. // If `view` is already a function, don't do anything.
  330. return view;
  331. }
  332. return ( modelData, viewWriter ) => _createViewElementFromDefinition( view, viewWriter, viewElementType );
  333. }
  334. // Creates view element instance from provided viewElementDefinition and class.
  335. //
  336. // @param {module:engine/view/elementdefinition~ElementDefinition} viewElementDefinition
  337. // @param {module:engine/view/writer~Writer} viewWriter
  338. // @param {'container'|'attribute'|'ui'} viewElementType
  339. // @returns {module:engine/view/element~Element}
  340. function _createViewElementFromDefinition( viewElementDefinition, viewWriter, viewElementType ) {
  341. if ( typeof viewElementDefinition == 'string' ) {
  342. // If `viewElementDefinition` is given as a `String`, normalize it to an object with `name` property.
  343. viewElementDefinition = { name: viewElementDefinition };
  344. }
  345. let element;
  346. if ( viewElementType == 'container' ) {
  347. element = viewWriter.createContainerElement( viewElementDefinition.name, Object.assign( {}, viewElementDefinition.attribute ) );
  348. } else if ( viewElementType == 'attribute' ) {
  349. element = viewWriter.createAttributeElement( viewElementDefinition.name, Object.assign( {}, viewElementDefinition.attribute ) );
  350. } else {
  351. // 'ui'.
  352. element = viewWriter.createUIElement( viewElementDefinition.name, Object.assign( {}, viewElementDefinition.attribute ) );
  353. }
  354. if ( viewElementDefinition.style ) {
  355. const keys = Object.keys( viewElementDefinition.style );
  356. for ( const key of keys ) {
  357. viewWriter.setStyle( key, viewElementDefinition.style[ key ], element );
  358. }
  359. }
  360. if ( viewElementDefinition.class ) {
  361. const classes = viewElementDefinition.class;
  362. if ( typeof classes == 'string' ) {
  363. viewWriter.addClass( classes, element );
  364. } else {
  365. for ( const className of classes ) {
  366. viewWriter.addClass( className, element );
  367. }
  368. }
  369. }
  370. return element;
  371. }
  372. function _getFromAttributeCreator( config ) {
  373. if ( config.model.values ) {
  374. return ( modelAttributeValue, viewWriter ) => {
  375. const view = config.view[ modelAttributeValue ];
  376. if ( view ) {
  377. return view( modelAttributeValue, viewWriter );
  378. }
  379. return null;
  380. };
  381. } else {
  382. return config.view;
  383. }
  384. }
  385. // Takes config and adds default parameters if they don't exist and normalizes other parameters to be used in downcast converters
  386. // for generating view attribute.
  387. //
  388. // @param {Object} view View configuration.
  389. function _normalizeToAttributeConfig( view ) {
  390. if ( typeof view == 'string' ) {
  391. return modelAttributeValue => ( { key: view, value: modelAttributeValue } );
  392. } else if ( typeof view == 'object' ) {
  393. return () => view;
  394. } else {
  395. // function.
  396. return view;
  397. }
  398. }
  399. /**
  400. * Function factory, creates a converter that converts node insertion changes from the model to the view.
  401. * Passed function will be provided with all the parameters of the dispatcher's
  402. * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert insert event}.
  403. * It's expected that the function returns a {@link module:engine/view/element~Element}.
  404. * The result of the function will be inserted to the view.
  405. *
  406. * The converter automatically consumes corresponding value from consumables list, stops the event (see
  407. * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}) and bind model and view elements.
  408. *
  409. * downcastDispatcher.on(
  410. * 'insert:myElem',
  411. * insertElement( ( modelItem, viewWriter ) => {
  412. * const text = viewWriter.createText( 'myText' );
  413. * const myElem = viewWriter.createElement( 'myElem', { myAttr: 'my-' + modelItem.getAttribute( 'myAttr' ) }, text );
  414. *
  415. * // Do something fancy with myElem using `modelItem` or other parameters.
  416. *
  417. * return myElem;
  418. * }
  419. * ) );
  420. *
  421. * @param {Function} elementCreator Function returning a view element, which will be inserted.
  422. * @returns {Function} Insert element event converter.
  423. */
  424. export function insertElement( elementCreator ) {
  425. return ( evt, data, conversionApi ) => {
  426. const viewElement = elementCreator( data.item, conversionApi.writer );
  427. if ( !viewElement ) {
  428. return;
  429. }
  430. if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
  431. return;
  432. }
  433. const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
  434. conversionApi.mapper.bindElements( data.item, viewElement );
  435. conversionApi.writer.insert( viewPosition, viewElement );
  436. };
  437. }
  438. /**
  439. * Function factory, creates a default downcast converter for text insertion changes.
  440. *
  441. * The converter automatically consumes corresponding value from consumables list and stops the event (see
  442. * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}).
  443. *
  444. * modelDispatcher.on( 'insert:$text', insertText() );
  445. *
  446. * @returns {Function} Insert text event converter.
  447. */
  448. export function insertText() {
  449. return ( evt, data, conversionApi ) => {
  450. if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
  451. return;
  452. }
  453. const viewWriter = conversionApi.writer;
  454. const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
  455. const viewText = viewWriter.createText( data.item.data );
  456. viewWriter.insert( viewPosition, viewText );
  457. };
  458. }
  459. /**
  460. * Function factory, creates a default downcast converter for node remove changes.
  461. *
  462. * modelDispatcher.on( 'remove', remove() );
  463. *
  464. * @returns {Function} Remove event converter.
  465. */
  466. export function remove() {
  467. return ( evt, data, conversionApi ) => {
  468. // Find view range start position by mapping model position at which the remove happened.
  469. const viewStart = conversionApi.mapper.toViewPosition( data.position );
  470. const modelEnd = data.position.getShiftedBy( data.length );
  471. const viewEnd = conversionApi.mapper.toViewPosition( modelEnd, { isPhantom: true } );
  472. const viewRange = new ViewRange( viewStart, viewEnd );
  473. // Trim the range to remove in case some UI elements are on the view range boundaries.
  474. const removed = conversionApi.writer.remove( viewRange.getTrimmed() );
  475. // After the range is removed, unbind all view elements from the model.
  476. // Range inside view document fragment is used to unbind deeply.
  477. for ( const child of ViewRange.createIn( removed ).getItems() ) {
  478. conversionApi.mapper.unbindViewElement( child );
  479. }
  480. };
  481. }
  482. /**
  483. * Function factory, creates a converter that converts marker adding change to the view ui element.
  484. *
  485. * The view ui element, that will be added to the view, depends on passed parameter. See {@link ~insertElement}.
  486. * In a case of a non-collapsed range, the ui element will not wrap nodes but separate elements will be placed at the beginning
  487. * and at the end of the range.
  488. *
  489. * This converter binds created {@link module:engine/view/uielement~UIElement}s with marker name using
  490. * the {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}.
  491. *
  492. * @param {module:engine/view/uielement~UIElement|Function} elementCreator View ui element or a function returning a view element
  493. * which will be inserted.
  494. * @returns {Function} Insert element event converter.
  495. */
  496. export function insertUIElement( elementCreator ) {
  497. return ( evt, data, conversionApi ) => {
  498. // Create two view elements. One will be inserted at the beginning of marker, one at the end.
  499. // If marker is collapsed, only "opening" element will be inserted.
  500. data.isOpening = true;
  501. const viewStartElement = elementCreator( data, conversionApi.writer );
  502. data.isOpening = false;
  503. const viewEndElement = elementCreator( data, conversionApi.writer );
  504. if ( !viewStartElement || !viewEndElement ) {
  505. return;
  506. }
  507. const markerRange = data.markerRange;
  508. // Marker that is collapsed has consumable build differently that non-collapsed one.
  509. // For more information see `addMarker` event description.
  510. // If marker's range is collapsed - check if it can be consumed.
  511. if ( markerRange.isCollapsed && !conversionApi.consumable.consume( markerRange, evt.name ) ) {
  512. return;
  513. }
  514. // If marker's range is not collapsed - consume all items inside.
  515. for ( const value of markerRange ) {
  516. if ( !conversionApi.consumable.consume( value.item, evt.name ) ) {
  517. return;
  518. }
  519. }
  520. const mapper = conversionApi.mapper;
  521. const viewWriter = conversionApi.writer;
  522. // Add "opening" element.
  523. viewWriter.insert( mapper.toViewPosition( markerRange.start ), viewStartElement );
  524. conversionApi.mapper.bindElementToMarker( viewStartElement, data.markerName );
  525. // Add "closing" element only if range is not collapsed.
  526. if ( !markerRange.isCollapsed ) {
  527. viewWriter.insert( mapper.toViewPosition( markerRange.end ), viewEndElement );
  528. conversionApi.mapper.bindElementToMarker( viewEndElement, data.markerName );
  529. }
  530. evt.stop();
  531. };
  532. }
  533. /**
  534. * Function factory, returns a default downcast converter for removing {@link module:engine/view/uielement~UIElement ui element}
  535. * basing on marker remove change.
  536. *
  537. * This converter unbinds elements from marker name.
  538. *
  539. * @returns {Function} Remove ui element converter.
  540. */
  541. export function removeUIElement() {
  542. return ( evt, data, conversionApi ) => {
  543. const elements = conversionApi.mapper.markerNameToElements( data.markerName );
  544. if ( !elements ) {
  545. return;
  546. }
  547. conversionApi.mapper.unbindElementsFromMarkerName( data.markerName );
  548. const viewWriter = conversionApi.writer;
  549. for ( const element of elements ) {
  550. viewWriter.clear( ViewRange.createOn( element ), element );
  551. }
  552. evt.stop();
  553. };
  554. }
  555. /**
  556. * Function factory, creates a converter that converts set/change/remove attribute changes from the model to the view.
  557. *
  558. * Attributes from model are converted to the view element attributes in the view. You may provide a custom function to generate
  559. * a key-value attribute pair to add/change/remove. If not provided, model attributes will be converted to view elements
  560. * attributes on 1-to-1 basis.
  561. *
  562. * **Note:** Provided attribute creator should always return the same `key` for given attribute from the model.
  563. *
  564. * The converter automatically consumes corresponding value from consumables list and stops the event (see
  565. * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}).
  566. *
  567. * modelDispatcher.on( 'attribute:customAttr:myElem', changeAttribute( ( value, data ) => {
  568. * // Change attribute key from `customAttr` to `class` in view.
  569. * const key = 'class';
  570. * let value = data.attributeNewValue;
  571. *
  572. * // Force attribute value to 'empty' if the model element is empty.
  573. * if ( data.item.childCount === 0 ) {
  574. * value = 'empty';
  575. * }
  576. *
  577. * // Return key-value pair.
  578. * return { key, value };
  579. * } ) );
  580. *
  581. * @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
  582. * represents attribute key and attribute value to be set on a {@link module:engine/view/element~Element view element}.
  583. * The function is passed model attribute value as first parameter and additional data about the change as a second parameter.
  584. * @returns {Function} Set/change attribute converter.
  585. */
  586. export function changeAttribute( attributeCreator ) {
  587. attributeCreator = attributeCreator || ( ( value, data ) => ( { value, key: data.attributeKey } ) );
  588. return ( evt, data, conversionApi ) => {
  589. if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
  590. return;
  591. }
  592. const viewElement = conversionApi.mapper.toViewElement( data.item );
  593. const viewWriter = conversionApi.writer;
  594. // First remove the old attribute if there was one.
  595. const oldAttribute = attributeCreator( data.attributeOldValue, data );
  596. if ( data.attributeOldValue !== null && oldAttribute ) {
  597. if ( oldAttribute.key == 'class' ) {
  598. const classes = Array.isArray( oldAttribute.value ) ? oldAttribute.value : [ oldAttribute.value ];
  599. for ( const className of classes ) {
  600. viewWriter.removeClass( className, viewElement );
  601. }
  602. } else if ( oldAttribute.key == 'style' ) {
  603. const keys = Object.keys( oldAttribute.value );
  604. for ( const key of keys ) {
  605. viewWriter.removeStyle( key, viewElement );
  606. }
  607. } else {
  608. viewWriter.removeAttribute( oldAttribute.key, viewElement );
  609. }
  610. }
  611. // Then, if conversion was successful, set the new attribute.
  612. const newAttribute = attributeCreator( data.attributeNewValue, data );
  613. if ( data.attributeNewValue !== null && newAttribute ) {
  614. if ( newAttribute.key == 'class' ) {
  615. const classes = Array.isArray( newAttribute.value ) ? newAttribute.value : [ newAttribute.value ];
  616. for ( const className of classes ) {
  617. viewWriter.addClass( className, viewElement );
  618. }
  619. } else if ( newAttribute.key == 'style' ) {
  620. const keys = Object.keys( newAttribute.value );
  621. for ( const key of keys ) {
  622. viewWriter.setStyle( key, newAttribute.value[ key ], viewElement );
  623. }
  624. } else {
  625. viewWriter.setAttribute( newAttribute.key, newAttribute.value, viewElement );
  626. }
  627. }
  628. };
  629. }
  630. /**
  631. * Function factory, creates a converter that converts set/change/remove attribute changes from the model to the view.
  632. * Also can be used to convert selection attributes. In that case, an empty attribute element will be created and the
  633. * selection will be put inside it.
  634. *
  635. * Attributes from model are converted to a view element that will be wrapping those view nodes that are bound to
  636. * model elements having given attribute. This is useful for attributes like `bold`, which may be set on text nodes in model
  637. * but are represented as an element in the view:
  638. *
  639. * [paragraph] MODEL ====> VIEW <p>
  640. * |- a {bold: true} |- <b>
  641. * |- b {bold: true} | |- ab
  642. * |- c |- c
  643. *
  644. * Passed `Function` will be provided with attribute value and then all the parameters of the
  645. * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute attribute event}.
  646. * It's expected that the function returns a {@link module:engine/view/element~Element}.
  647. * The result of the function will be the wrapping element.
  648. * When provided `Function` does not return element, then will be no conversion.
  649. *
  650. * The converter automatically consumes corresponding value from consumables list, stops the event (see
  651. * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}).
  652. *
  653. * modelDispatcher.on( 'attribute:bold', wrapItem( ( modelAttributeValue, viewWriter ) => {
  654. * return viewWriter.createAttributeElement( 'strong' );
  655. * } );
  656. *
  657. * @param {Function} elementCreator Function returning a view element, which will be used for wrapping.
  658. * @returns {Function} Set/change attribute converter.
  659. */
  660. export function wrap( elementCreator ) {
  661. return ( evt, data, conversionApi ) => {
  662. // Recreate current wrapping node. It will be used to unwrap view range if the attribute value has changed
  663. // or the attribute was removed.
  664. const oldViewElement = elementCreator( data.attributeOldValue, conversionApi.writer );
  665. // Create node to wrap with.
  666. const newViewElement = elementCreator( data.attributeNewValue, conversionApi.writer );
  667. if ( !oldViewElement && !newViewElement ) {
  668. return;
  669. }
  670. if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
  671. return;
  672. }
  673. const viewWriter = conversionApi.writer;
  674. const viewSelection = viewWriter.document.selection;
  675. if ( data.item instanceof ModelSelection || data.item instanceof DocumentSelection ) {
  676. // Selection attribute conversion.
  677. viewWriter.wrap( viewSelection.getFirstRange(), newViewElement );
  678. } else {
  679. // Node attribute conversion.
  680. let viewRange = conversionApi.mapper.toViewRange( data.range );
  681. // First, unwrap the range from current wrapper.
  682. if ( data.attributeOldValue !== null && oldViewElement ) {
  683. viewRange = viewWriter.unwrap( viewRange, oldViewElement );
  684. }
  685. if ( data.attributeNewValue !== null && newViewElement ) {
  686. viewWriter.wrap( viewRange, newViewElement );
  687. }
  688. }
  689. };
  690. }
  691. /**
  692. * Function factory, creates converter that converts text inside marker's range. Converter wraps the text with
  693. * {@link module:engine/view/attributeelement~AttributeElement} created from provided descriptor.
  694. * See {link module:engine/conversion/downcast-converters~createViewElementFromHighlightDescriptor}.
  695. *
  696. * Also can be used to convert selection that is inside a marker. In that case, an empty attribute element will be
  697. * created and the selection will be put inside it.
  698. *
  699. * If the highlight descriptor will not provide `priority` property, `10` will be used.
  700. *
  701. * If the highlight descriptor will not provide `id` property, name of the marker will be used.
  702. *
  703. * This converter binds created {@link module:engine/view/attributeelement~AttributeElement}s with marker name using
  704. * the {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}.
  705. *
  706. * @param {module:engine/conversion/downcast-converters~HighlightDescriptor|Function} highlightDescriptor
  707. * @return {Function}
  708. */
  709. export function highlightText( highlightDescriptor ) {
  710. return ( evt, data, conversionApi ) => {
  711. if ( data.markerRange.isCollapsed ) {
  712. return;
  713. }
  714. if ( !( data.item instanceof ModelSelection || data.item instanceof DocumentSelection ) && !data.item.is( 'textProxy' ) ) {
  715. return;
  716. }
  717. const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
  718. if ( !descriptor ) {
  719. return;
  720. }
  721. if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
  722. return;
  723. }
  724. const viewElement = createViewElementFromHighlightDescriptor( descriptor );
  725. const viewWriter = conversionApi.writer;
  726. const viewSelection = viewWriter.document.selection;
  727. if ( data.item instanceof ModelSelection || data.item instanceof DocumentSelection ) {
  728. viewWriter.wrap( viewSelection.getFirstRange(), viewElement, viewSelection );
  729. } else {
  730. const viewRange = conversionApi.mapper.toViewRange( data.range );
  731. const rangeAfterWrap = viewWriter.wrap( viewRange, viewElement );
  732. for ( const element of rangeAfterWrap.getItems() ) {
  733. if ( element.is( 'attributeElement' ) && element.isSimilar( viewElement ) ) {
  734. conversionApi.mapper.bindElementToMarker( element, data.markerName );
  735. // One attribute element is enough, because all of them are bound together by the view writer.
  736. // Mapper uses this binding to get all the elements no matter how many of them are registered in the mapper.
  737. break;
  738. }
  739. }
  740. }
  741. };
  742. }
  743. /**
  744. * Converter function factory. Creates a function which applies the marker's highlight to an element inside the marker's range.
  745. *
  746. * The converter checks if an element has `addHighlight` function stored as
  747. * {@link module:engine/view/element~Element#_setCustomProperty custom property} and, if so, uses it to apply the highlight.
  748. * In such case converter will consume all element's children, assuming that they were handled by element itself.
  749. *
  750. * When `addHighlight` custom property is not present, element is not converted in any special way.
  751. * This means that converters will proceed to convert element's child nodes.
  752. *
  753. * If the highlight descriptor will not provide `priority` property, `10` will be used.
  754. *
  755. * If the highlight descriptor will not provide `id` property, name of the marker will be used.
  756. *
  757. * This converter binds altered {@link module:engine/view/containerelement~ContainerElement}s with marker name using
  758. * the {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}.
  759. *
  760. * @param {module:engine/conversion/downcast-converters~HighlightDescriptor|Function} highlightDescriptor
  761. * @return {Function}
  762. */
  763. export function highlightElement( highlightDescriptor ) {
  764. return ( evt, data, conversionApi ) => {
  765. if ( data.markerRange.isCollapsed ) {
  766. return;
  767. }
  768. if ( !( data.item instanceof ModelElement ) ) {
  769. return;
  770. }
  771. const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
  772. if ( !descriptor ) {
  773. return;
  774. }
  775. if ( !conversionApi.consumable.test( data.item, evt.name ) ) {
  776. return;
  777. }
  778. const viewElement = conversionApi.mapper.toViewElement( data.item );
  779. if ( viewElement && viewElement.getCustomProperty( 'addHighlight' ) ) {
  780. // Consume element itself.
  781. conversionApi.consumable.consume( data.item, evt.name );
  782. // Consume all children nodes.
  783. for ( const value of ModelRange.createIn( data.item ) ) {
  784. conversionApi.consumable.consume( value.item, evt.name );
  785. }
  786. viewElement.getCustomProperty( 'addHighlight' )( viewElement, descriptor, conversionApi.writer );
  787. conversionApi.mapper.bindElementToMarker( viewElement, data.markerName );
  788. }
  789. };
  790. }
  791. /**
  792. * Function factory, creates a converter that converts removing model marker to the view.
  793. *
  794. * Both text nodes and elements are handled by this converter but they are handled a bit differently.
  795. *
  796. * Text nodes are unwrapped using {@link module:engine/view/attributeelement~AttributeElement} created from provided
  797. * highlight descriptor. See {link module:engine/conversion/downcast-converters~highlightDescriptorToAttributeElement}.
  798. *
  799. * For elements, the converter checks if an element has `removeHighlight` function stored as
  800. * {@link module:engine/view/element~Element#_setCustomProperty custom property}. If so, it uses it to remove the highlight.
  801. * In such case, children of that element will not be converted.
  802. *
  803. * When `removeHighlight` is not present, element is not converted in any special way.
  804. * Instead converter will proceed to convert element's child nodes.
  805. *
  806. * If the highlight descriptor will not provide `priority` property, `10` will be used.
  807. *
  808. * If the highlight descriptor will not provide `id` property, name of the marker will be used.
  809. *
  810. * This converter unbinds elements from marker name.
  811. *
  812. * @param {module:engine/conversion/downcast-converters~HighlightDescriptor|Function} highlightDescriptor
  813. * @return {Function}
  814. */
  815. export function removeHighlight( highlightDescriptor ) {
  816. return ( evt, data, conversionApi ) => {
  817. // This conversion makes sense only for non-collapsed range.
  818. if ( data.markerRange.isCollapsed ) {
  819. return;
  820. }
  821. const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
  822. if ( !descriptor ) {
  823. return;
  824. }
  825. // View element that will be used to unwrap `AttributeElement`s.
  826. const viewHighlightElement = createViewElementFromHighlightDescriptor( descriptor );
  827. // Get all elements bound with given marker name.
  828. const elements = conversionApi.mapper.markerNameToElements( data.markerName );
  829. if ( !elements ) {
  830. return;
  831. }
  832. conversionApi.mapper.unbindElementsFromMarkerName( data.markerName );
  833. for ( const element of elements ) {
  834. if ( element.is( 'attributeElement' ) ) {
  835. conversionApi.writer.unwrap( ViewRange.createOn( element ), viewHighlightElement );
  836. } else {
  837. // if element.is( 'containerElement' ).
  838. element.getCustomProperty( 'removeHighlight' )( element, descriptor.id, conversionApi.writer );
  839. }
  840. }
  841. evt.stop();
  842. };
  843. }
  844. // Helper function for `highlight`. Prepares the actual descriptor object using value passed to the converter.
  845. function _prepareDescriptor( highlightDescriptor, data, conversionApi ) {
  846. // If passed descriptor is a creator function, call it. If not, just use passed value.
  847. const descriptor = typeof highlightDescriptor == 'function' ?
  848. highlightDescriptor( data, conversionApi ) :
  849. highlightDescriptor;
  850. if ( !descriptor ) {
  851. return null;
  852. }
  853. // Apply default descriptor priority.
  854. if ( !descriptor.priority ) {
  855. descriptor.priority = 10;
  856. }
  857. // Default descriptor id is marker name.
  858. if ( !descriptor.id ) {
  859. descriptor.id = data.markerName;
  860. }
  861. return descriptor;
  862. }
  863. /**
  864. * Creates `span` {@link module:engine/view/attributeelement~AttributeElement view attribute element} from information
  865. * provided by {@link module:engine/conversion/downcast-converters~HighlightDescriptor} object. If priority
  866. * is not provided in descriptor - default priority will be used.
  867. *
  868. * @param {module:engine/conversion/downcast-converters~HighlightDescriptor} descriptor
  869. * @returns {module:engine/view/attributeelement~AttributeElement}
  870. */
  871. export function createViewElementFromHighlightDescriptor( descriptor ) {
  872. const viewElement = new ViewAttributeElement( 'span', descriptor.attributes );
  873. if ( descriptor.class ) {
  874. viewElement._addClass( descriptor.class );
  875. }
  876. if ( descriptor.priority ) {
  877. viewElement._priority = descriptor.priority;
  878. }
  879. viewElement._id = descriptor.id;
  880. return viewElement;
  881. }
  882. /**
  883. * Object describing how the marker highlight should be represented in the view.
  884. *
  885. * Each text node contained in a highlighted range will be wrapped in a `span` {@link module:engine/view/attributeelement~AttributeElement}
  886. * with CSS class(es), attributes and priority described by this object.
  887. *
  888. * Additionally, each {@link module:engine/view/containerelement~ContainerElement} can handle displaying the highlight separately
  889. * by providing `addHighlight` and `removeHighlight` custom properties. In this case:
  890. *
  891. * * `HighlightDescriptor` object is passed to the `addHighlight` function upon conversion and should be used to apply the highlight to
  892. * the element,
  893. * * descriptor `id` is passed to the `removeHighlight` function upon conversion and should be used to remove the highlight of given
  894. * id from the element.
  895. *
  896. * @typedef {Object} module:engine/conversion/downcast-converters~HighlightDescriptor
  897. *
  898. * @property {String|Array.<String>} class CSS class or array of classes to set. If descriptor is used to
  899. * create {@link module:engine/view/attributeelement~AttributeElement} over text nodes, those classes will be set
  900. * on that {@link module:engine/view/attributeelement~AttributeElement}. If descriptor is applied to an element,
  901. * usually those class will be set on that element, however this depends on how the element converts the descriptor.
  902. *
  903. * @property {String} [id] Descriptor identifier. If not provided, defaults to converted marker's name.
  904. *
  905. * @property {Number} [priority] Descriptor priority. If not provided, defaults to `10`. If descriptor is used to create
  906. * {@link module:engine/view/attributeelement~AttributeElement}, it will be that element's
  907. * {@link module:engine/view/attributeelement~AttributeElement#priority}. If descriptor is applied to an element,
  908. * the priority will be used to determine which descriptor is more important.
  909. *
  910. * @property {Object} [attributes] Attributes to set. If descriptor is used to create
  911. * {@link module:engine/view/attributeelement~AttributeElement} over text nodes, those attributes will be set on that
  912. * {@link module:engine/view/attributeelement~AttributeElement}. If descriptor is applied to an element, usually those
  913. * attributes will be set on that element, however this depends on how the element converts the descriptor.
  914. */