downcasthelpers.js 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * Contains downcast (model-to-view) converters for {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}.
  7. *
  8. * @module engine/conversion/downcasthelpers
  9. */
  10. import ModelRange from '../model/range';
  11. import ModelSelection from '../model/selection';
  12. import ModelElement from '../model/element';
  13. import ViewAttributeElement from '../view/attributeelement';
  14. import DocumentSelection from '../model/documentselection';
  15. import ConversionHelpers from './conversionhelpers';
  16. import { cloneDeep } from 'lodash-es';
  17. import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  18. /**
  19. * Downcast conversion helper functions.
  20. *
  21. * @extends module:engine/conversion/conversionhelpers~ConversionHelpers
  22. */
  23. export default class DowncastHelpers extends ConversionHelpers {
  24. /**
  25. * Model element to view element conversion helper.
  26. *
  27. * This conversion results in creating a view element. For example, model `<paragraph>Foo</paragraph>` becomes `<p>Foo</p>` in the view.
  28. *
  29. * editor.conversion.for( 'downcast' ).elementToElement( {
  30. * model: 'paragraph',
  31. * view: 'p'
  32. * } );
  33. *
  34. * editor.conversion.for( 'downcast' ).elementToElement( {
  35. * model: 'paragraph',
  36. * view: 'div',
  37. * converterPriority: 'high'
  38. * } );
  39. *
  40. * editor.conversion.for( 'downcast' ).elementToElement( {
  41. * model: 'fancyParagraph',
  42. * view: {
  43. * name: 'p',
  44. * classes: 'fancy'
  45. * }
  46. * } );
  47. *
  48. * editor.conversion.for( 'downcast' ).elementToElement( {
  49. * model: 'heading',
  50. * view: ( modelElement, viewWriter ) => {
  51. * return viewWriter.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) )
  52. * }
  53. * } );
  54. *
  55. * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
  56. * to the conversion process.
  57. *
  58. * @method #elementToElement
  59. * @param {Object} config Conversion configuration.
  60. * @param {String} config.model The name of the model element to convert.
  61. * @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function
  62. * that takes the model element and {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer}
  63. * as parameters and returns a view container element.
  64. * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
  65. */
  66. elementToElement( config ) {
  67. return this.add( downcastElementToElement( config ) );
  68. }
  69. /**
  70. * Model attribute to view element conversion helper.
  71. *
  72. * This conversion results in wrapping view nodes with a view attribute element. For example, a model text node with
  73. * `"Foo"` as data and the `bold` attribute becomes `<strong>Foo</strong>` in the view.
  74. *
  75. * editor.conversion.for( 'downcast' ).attributeToElement( {
  76. * model: 'bold',
  77. * view: 'strong'
  78. * } );
  79. *
  80. * editor.conversion.for( 'downcast' ).attributeToElement( {
  81. * model: 'bold',
  82. * view: 'b',
  83. * converterPriority: 'high'
  84. * } );
  85. *
  86. * editor.conversion.for( 'downcast' ).attributeToElement( {
  87. * model: 'invert',
  88. * view: {
  89. * name: 'span',
  90. * classes: [ 'font-light', 'bg-dark' ]
  91. * }
  92. * } );
  93. *
  94. * editor.conversion.for( 'downcast' ).attributeToElement( {
  95. * model: {
  96. * key: 'fontSize',
  97. * values: [ 'big', 'small' ]
  98. * },
  99. * view: {
  100. * big: {
  101. * name: 'span',
  102. * styles: {
  103. * 'font-size': '1.2em'
  104. * }
  105. * },
  106. * small: {
  107. * name: 'span',
  108. * styles: {
  109. * 'font-size': '0.8em'
  110. * }
  111. * }
  112. * }
  113. * } );
  114. *
  115. * editor.conversion.for( 'downcast' ).attributeToElement( {
  116. * model: 'bold',
  117. * view: ( modelAttributeValue, viewWriter ) => {
  118. * return viewWriter.createAttributeElement( 'span', {
  119. * style: 'font-weight:' + modelAttributeValue
  120. * } );
  121. * }
  122. * } );
  123. *
  124. * editor.conversion.for( 'downcast' ).attributeToElement( {
  125. * model: {
  126. * key: 'color',
  127. * name: '$text'
  128. * },
  129. * view: ( modelAttributeValue, viewWriter ) => {
  130. * return viewWriter.createAttributeElement( 'span', {
  131. * style: 'color:' + modelAttributeValue
  132. * } );
  133. * }
  134. * } );
  135. *
  136. * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
  137. * to the conversion process.
  138. *
  139. * @method #attributeToElement
  140. * @param {Object} config Conversion configuration.
  141. * @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values }` object. `values` is an array
  142. * of `String`s with possible values if the model attribute is an enumerable.
  143. * @param {module:engine/view/elementdefinition~ElementDefinition|Function|Object} config.view A view element definition or a function
  144. * that takes the model attribute value and {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer}
  145. * as parameters and returns a view attribute element. If `config.model.values` is
  146. * given, `config.view` should be an object assigning values from `config.model.values` to view element definitions or functions.
  147. * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
  148. * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
  149. */
  150. attributeToElement( config ) {
  151. return this.add( downcastAttributeToElement( config ) );
  152. }
  153. /**
  154. * Model attribute to view attribute conversion helper.
  155. *
  156. * This conversion results in adding an attribute to a view node, basing on an attribute from a model node. For example,
  157. * `<image src='foo.jpg'></image>` is converted to `<img src='foo.jpg'></img>`.
  158. *
  159. * editor.conversion.for( 'downcast' ).attributeToAttribute( {
  160. * model: 'source',
  161. * view: 'src'
  162. * } );
  163. *
  164. * editor.conversion.for( 'downcast' ).attributeToAttribute( {
  165. * model: 'source',
  166. * view: 'href',
  167. * converterPriority: 'high'
  168. * } );
  169. *
  170. * editor.conversion.for( 'downcast' ).attributeToAttribute( {
  171. * model: {
  172. * name: 'image',
  173. * key: 'source'
  174. * },
  175. * view: 'src'
  176. * } );
  177. *
  178. * editor.conversion.for( 'downcast' ).attributeToAttribute( {
  179. * model: {
  180. * name: 'styled',
  181. * values: [ 'dark', 'light' ]
  182. * },
  183. * view: {
  184. * dark: {
  185. * key: 'class',
  186. * value: [ 'styled', 'styled-dark' ]
  187. * },
  188. * light: {
  189. * key: 'class',
  190. * value: [ 'styled', 'styled-light' ]
  191. * }
  192. * }
  193. * } );
  194. *
  195. * editor.conversion.for( 'downcast' ).attributeToAttribute( {
  196. * model: 'styled',
  197. * view: modelAttributeValue => ( { key: 'class', value: 'styled-' + modelAttributeValue } )
  198. * } );
  199. *
  200. * **Note**: Downcasting to a style property requires providing `value` as an object:
  201. *
  202. * editor.conversion.for( 'downcast' ).attributeToAttribute( {
  203. * model: 'lineHeight',
  204. * view: modelAttributeValue => ( {
  205. * key: 'style',
  206. * value: {
  207. * 'line-height': modelAttributeValue,
  208. * 'border-bottom': '1px dotted #ba2'
  209. * }
  210. * } )
  211. * } );
  212. *
  213. * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
  214. * to the conversion process.
  215. *
  216. * @method #attributeToAttribute
  217. * @param {Object} config Conversion configuration.
  218. * @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values, [ name ] }` object describing
  219. * the attribute key, possible values and, optionally, an element name to convert from.
  220. * @param {String|Object|Function} config.view A view attribute key, or a `{ key, value }` object or a function that takes
  221. * the model attribute value and returns a `{ key, value }` object. If `key` is `'class'`, `value` can be a `String` or an
  222. * array of `String`s. If `key` is `'style'`, `value` is an object with key-value pairs. In other cases, `value` is a `String`.
  223. * If `config.model.values` is set, `config.view` should be an object assigning values from `config.model.values` to
  224. * `{ key, value }` objects or a functions.
  225. * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
  226. * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
  227. */
  228. attributeToAttribute( config ) {
  229. return this.add( downcastAttributeToAttribute( config ) );
  230. }
  231. /**
  232. * Model marker to view element conversion helper.
  233. *
  234. * This conversion results in creating a view element on the boundaries of the converted marker. If the converted marker
  235. * is collapsed, only one element is created. For example, model marker set like this: `<paragraph>F[oo b]ar</paragraph>`
  236. * becomes `<p>F<span data-marker="search"></span>oo b<span data-marker="search"></span>ar</p>` in the view.
  237. *
  238. * editor.conversion.for( 'downcast' ).markerToElement( {
  239. * model: 'search',
  240. * view: 'marker-search'
  241. * } );
  242. *
  243. * editor.conversion.for( 'downcast' ).markerToElement( {
  244. * model: 'search',
  245. * view: 'search-result',
  246. * converterPriority: 'high'
  247. * } );
  248. *
  249. * editor.conversion.for( 'downcast' ).markerToElement( {
  250. * model: 'search',
  251. * view: {
  252. * name: 'span',
  253. * attributes: {
  254. * 'data-marker': 'search'
  255. * }
  256. * }
  257. * } );
  258. *
  259. * editor.conversion.for( 'downcast' ).markerToElement( {
  260. * model: 'search',
  261. * view: ( markerData, viewWriter ) => {
  262. * return viewWriter.createUIElement( 'span', {
  263. * 'data-marker': 'search',
  264. * 'data-start': markerData.isOpening
  265. * } );
  266. * }
  267. * } );
  268. *
  269. * If a function is passed as the `config.view` parameter, it will be used to generate both boundary elements. The function
  270. * receives the `data` object as a parameter and should return an instance of the
  271. * {@link module:engine/view/uielement~UIElement view UI element}. The `data` object and
  272. * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi `conversionApi`} are passed from
  273. * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker}. Additionally,
  274. * the `data.isOpening` parameter is passed, which is set to `true` for the marker start boundary element, and `false` to
  275. * the marker end boundary element.
  276. *
  277. * This kind of conversion is useful for saving data into the database, so it should be used in the data conversion pipeline.
  278. *
  279. * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
  280. * to the conversion process.
  281. *
  282. * @method #markerToElement
  283. * @param {Object} config Conversion configuration.
  284. * @param {String} config.model The name of the model marker (or model marker group) to convert.
  285. * @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function
  286. * that takes the model marker data as a parameter and returns a view UI element.
  287. * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
  288. * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
  289. */
  290. markerToElement( config ) {
  291. return this.add( downcastMarkerToElement( config ) );
  292. }
  293. /**
  294. * Model marker to highlight conversion helper.
  295. *
  296. * This conversion results in creating a highlight on view nodes. For this kind of conversion,
  297. * {@link module:engine/conversion/downcasthelpers~HighlightDescriptor} should be provided.
  298. *
  299. * For text nodes, a `<span>` {@link module:engine/view/attributeelement~AttributeElement} is created and it wraps all text nodes
  300. * in the converted marker range. For example, a model marker set like this: `<paragraph>F[oo b]ar</paragraph>` becomes
  301. * `<p>F<span class="comment">oo b</span>ar</p>` in the view.
  302. *
  303. * {@link module:engine/view/containerelement~ContainerElement} may provide a custom way of handling highlight. Most often,
  304. * the element itself is given classes and attributes described in the highlight descriptor (instead of being wrapped in `<span>`).
  305. * For example, a model marker set like this: `[<image src="foo.jpg"></image>]` becomes `<img src="foo.jpg" class="comment"></img>`
  306. * in the view.
  307. *
  308. * For container elements, the conversion is two-step. While the converter processes the highlight descriptor and passes it
  309. * to a container element, it is the container element instance itself that applies values from the highlight descriptor.
  310. * So, in a sense, the converter takes care of stating what should be applied on what, while the element decides how to apply that.
  311. *
  312. * editor.conversion.for( 'downcast' ).markerToHighlight( { model: 'comment', view: { classes: 'comment' } } );
  313. *
  314. * editor.conversion.for( 'downcast' ).markerToHighlight( {
  315. * model: 'comment',
  316. * view: { classes: 'new-comment' },
  317. * converterPriority: 'high'
  318. * } );
  319. *
  320. * editor.conversion.for( 'downcast' ).markerToHighlight( {
  321. * model: 'comment',
  322. * view: data => {
  323. * // Assuming that the marker name is in a form of comment:commentType.
  324. * const commentType = data.markerName.split( ':' )[ 1 ];
  325. *
  326. * return {
  327. * classes: [ 'comment', 'comment-' + commentType ]
  328. * };
  329. * }
  330. * } );
  331. *
  332. * If a function is passed as the `config.view` parameter, it will be used to generate the highlight descriptor. The function
  333. * receives the `data` object as a parameter and should return a
  334. * {@link module:engine/conversion/downcasthelpers~HighlightDescriptor highlight descriptor}.
  335. * The `data` object properties are passed from {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker}.
  336. *
  337. * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
  338. * to the conversion process.
  339. *
  340. * @method #markerToHighlight
  341. * @param {Object} config Conversion configuration.
  342. * @param {String} config.model The name of the model marker (or model marker group) to convert.
  343. * @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} config.view A highlight descriptor
  344. * that will be used for highlighting or a function that takes the model marker data as a parameter and returns a highlight descriptor.
  345. * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
  346. * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
  347. */
  348. markerToHighlight( config ) {
  349. return this.add( downcastMarkerToHighlight( config ) );
  350. }
  351. }
  352. /**
  353. * Function factory that creates a default downcast converter for text insertion changes.
  354. *
  355. * The converter automatically consumes the corresponding value from the consumables list and stops the event (see
  356. * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}).
  357. *
  358. * modelDispatcher.on( 'insert:$text', insertText() );
  359. *
  360. * @returns {Function} Insert text event converter.
  361. */
  362. export function insertText() {
  363. return ( evt, data, conversionApi ) => {
  364. if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
  365. return;
  366. }
  367. const viewWriter = conversionApi.writer;
  368. const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
  369. const viewText = viewWriter.createText( data.item.data );
  370. viewWriter.insert( viewPosition, viewText );
  371. };
  372. }
  373. /**
  374. * Function factory that creates a default downcast converter for node remove changes.
  375. *
  376. * modelDispatcher.on( 'remove', remove() );
  377. *
  378. * @returns {Function} Remove event converter.
  379. */
  380. export function remove() {
  381. return ( evt, data, conversionApi ) => {
  382. // Find view range start position by mapping model position at which the remove happened.
  383. const viewStart = conversionApi.mapper.toViewPosition( data.position );
  384. const modelEnd = data.position.getShiftedBy( data.length );
  385. const viewEnd = conversionApi.mapper.toViewPosition( modelEnd, { isPhantom: true } );
  386. const viewRange = conversionApi.writer.createRange( viewStart, viewEnd );
  387. // Trim the range to remove in case some UI elements are on the view range boundaries.
  388. const removed = conversionApi.writer.remove( viewRange.getTrimmed() );
  389. // After the range is removed, unbind all view elements from the model.
  390. // Range inside view document fragment is used to unbind deeply.
  391. for ( const child of conversionApi.writer.createRangeIn( removed ).getItems() ) {
  392. conversionApi.mapper.unbindViewElement( child );
  393. }
  394. };
  395. }
  396. /**
  397. * Creates a `<span>` {@link module:engine/view/attributeelement~AttributeElement view attribute element} from the information
  398. * provided by the {@link module:engine/conversion/downcasthelpers~HighlightDescriptor highlight descriptor} object. If a priority
  399. * is not provided in the descriptor, the default priority will be used.
  400. *
  401. * @param {module:engine/view/downcastwriter~DowncastWriter} writer
  402. * @param {module:engine/conversion/downcasthelpers~HighlightDescriptor} descriptor
  403. * @returns {module:engine/view/attributeelement~AttributeElement}
  404. */
  405. export function createViewElementFromHighlightDescriptor( writer, descriptor ) {
  406. const viewElement = writer.createAttributeElement( 'span', descriptor.attributes );
  407. if ( descriptor.classes ) {
  408. viewElement._addClass( descriptor.classes );
  409. }
  410. if ( descriptor.priority ) {
  411. viewElement._priority = descriptor.priority;
  412. }
  413. viewElement._id = descriptor.id;
  414. return viewElement;
  415. }
  416. /**
  417. * Function factory that creates a converter which converts a non-collapsed {@link module:engine/model/selection~Selection model selection}
  418. * to a {@link module:engine/view/documentselection~DocumentSelection view selection}. The converter consumes appropriate
  419. * value from the `consumable` object and maps model positions from the selection to view positions.
  420. *
  421. * modelDispatcher.on( 'selection', convertRangeSelection() );
  422. *
  423. * @returns {Function} Selection converter.
  424. */
  425. export function convertRangeSelection() {
  426. return ( evt, data, conversionApi ) => {
  427. const selection = data.selection;
  428. if ( selection.isCollapsed ) {
  429. return;
  430. }
  431. if ( !conversionApi.consumable.consume( selection, 'selection' ) ) {
  432. return;
  433. }
  434. const viewRanges = [];
  435. for ( const range of selection.getRanges() ) {
  436. const viewRange = conversionApi.mapper.toViewRange( range );
  437. viewRanges.push( viewRange );
  438. }
  439. conversionApi.writer.setSelection( viewRanges, { backward: selection.isBackward } );
  440. };
  441. }
  442. /**
  443. * Function factory that creates a converter which converts a collapsed {@link module:engine/model/selection~Selection model selection} to
  444. * a {@link module:engine/view/documentselection~DocumentSelection view selection}. The converter consumes appropriate
  445. * value from the `consumable` object, maps the model selection position to the view position and breaks
  446. * {@link module:engine/view/attributeelement~AttributeElement attribute elements} at the selection position.
  447. *
  448. * modelDispatcher.on( 'selection', convertCollapsedSelection() );
  449. *
  450. * An example of the view state before and after converting the collapsed selection:
  451. *
  452. * <p><strong>f^oo<strong>bar</p>
  453. * -> <p><strong>f</strong>^<strong>oo</strong>bar</p>
  454. *
  455. * By breaking attribute elements like `<strong>`, the selection is in a correct element. Then, when the selection attribute is
  456. * converted, broken attributes might be merged again, or the position where the selection is may be wrapped
  457. * with different, appropriate attribute elements.
  458. *
  459. * See also {@link module:engine/conversion/downcasthelpers~clearAttributes} which does a clean-up
  460. * by merging attributes.
  461. *
  462. * @returns {Function} Selection converter.
  463. */
  464. export function convertCollapsedSelection() {
  465. return ( evt, data, conversionApi ) => {
  466. const selection = data.selection;
  467. if ( !selection.isCollapsed ) {
  468. return;
  469. }
  470. if ( !conversionApi.consumable.consume( selection, 'selection' ) ) {
  471. return;
  472. }
  473. const viewWriter = conversionApi.writer;
  474. const modelPosition = selection.getFirstPosition();
  475. const viewPosition = conversionApi.mapper.toViewPosition( modelPosition );
  476. const brokenPosition = viewWriter.breakAttributes( viewPosition );
  477. viewWriter.setSelection( brokenPosition );
  478. };
  479. }
  480. /**
  481. * Function factory that creates a converter which clears artifacts after the previous
  482. * {@link module:engine/model/selection~Selection model selection} conversion. It removes all empty
  483. * {@link module:engine/view/attributeelement~AttributeElement view attribute elements} and merges sibling attributes at all start and end
  484. * positions of all ranges.
  485. *
  486. * <p><strong>^</strong></p>
  487. * -> <p>^</p>
  488. *
  489. * <p><strong>foo</strong>^<strong>bar</strong>bar</p>
  490. * -> <p><strong>foo^bar<strong>bar</p>
  491. *
  492. * <p><strong>foo</strong><em>^</em><strong>bar</strong>bar</p>
  493. * -> <p><strong>foo^bar<strong>bar</p>
  494. *
  495. * This listener should be assigned before any converter for the new selection:
  496. *
  497. * modelDispatcher.on( 'selection', clearAttributes() );
  498. *
  499. * See {@link module:engine/conversion/downcasthelpers~convertCollapsedSelection}
  500. * which does the opposite by breaking attributes in the selection position.
  501. *
  502. * @returns {Function} Selection converter.
  503. */
  504. export function clearAttributes() {
  505. return ( evt, data, conversionApi ) => {
  506. const viewWriter = conversionApi.writer;
  507. const viewSelection = viewWriter.document.selection;
  508. for ( const range of viewSelection.getRanges() ) {
  509. // Not collapsed selection should not have artifacts.
  510. if ( range.isCollapsed ) {
  511. // Position might be in the node removed by the view writer.
  512. if ( range.end.parent.isAttached() ) {
  513. conversionApi.writer.mergeAttributes( range.start );
  514. }
  515. }
  516. }
  517. viewWriter.setSelection( null );
  518. };
  519. }
  520. /**
  521. * Function factory that creates a converter which converts set/change/remove attribute changes from the model to the view.
  522. * It can also be used to convert selection attributes. In that case, an empty attribute element will be created and the
  523. * selection will be put inside it.
  524. *
  525. * Attributes from the model are converted to a view element that will be wrapping these view nodes that are bound to
  526. * model elements having the given attribute. This is useful for attributes like `bold` that may be set on text nodes in the model
  527. * but are represented as an element in the view:
  528. *
  529. * [paragraph] MODEL ====> VIEW <p>
  530. * |- a {bold: true} |- <b>
  531. * |- b {bold: true} | |- ab
  532. * |- c |- c
  533. *
  534. * Passed `Function` will be provided with the attribute value and then all the parameters of the
  535. * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute` event}.
  536. * It is expected that the function returns an {@link module:engine/view/element~Element}.
  537. * The result of the function will be the wrapping element.
  538. * When the provided `Function` does not return any element, no conversion will take place.
  539. *
  540. * The converter automatically consumes the corresponding value from the consumables list and stops the event (see
  541. * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}).
  542. *
  543. * modelDispatcher.on( 'attribute:bold', wrap( ( modelAttributeValue, viewWriter ) => {
  544. * return viewWriter.createAttributeElement( 'strong' );
  545. * } );
  546. *
  547. * @protected
  548. * @param {Function} elementCreator Function returning a view element that will be used for wrapping.
  549. * @returns {Function} Set/change attribute converter.
  550. */
  551. export function wrap( elementCreator ) {
  552. return ( evt, data, conversionApi ) => {
  553. // Recreate current wrapping node. It will be used to unwrap view range if the attribute value has changed
  554. // or the attribute was removed.
  555. const oldViewElement = elementCreator( data.attributeOldValue, conversionApi.writer );
  556. // Create node to wrap with.
  557. const newViewElement = elementCreator( data.attributeNewValue, conversionApi.writer );
  558. if ( !oldViewElement && !newViewElement ) {
  559. return;
  560. }
  561. if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
  562. return;
  563. }
  564. const viewWriter = conversionApi.writer;
  565. const viewSelection = viewWriter.document.selection;
  566. if ( data.item instanceof ModelSelection || data.item instanceof DocumentSelection ) {
  567. // Selection attribute conversion.
  568. viewWriter.wrap( viewSelection.getFirstRange(), newViewElement );
  569. } else {
  570. // Node attribute conversion.
  571. let viewRange = conversionApi.mapper.toViewRange( data.range );
  572. // First, unwrap the range from current wrapper.
  573. if ( data.attributeOldValue !== null && oldViewElement ) {
  574. viewRange = viewWriter.unwrap( viewRange, oldViewElement );
  575. }
  576. if ( data.attributeNewValue !== null && newViewElement ) {
  577. viewWriter.wrap( viewRange, newViewElement );
  578. }
  579. }
  580. };
  581. }
  582. /**
  583. * Function factory that creates a converter which converts node insertion changes from the model to the view.
  584. * The function passed will be provided with all the parameters of the dispatcher's
  585. * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert `insert` event}.
  586. * It is expected that the function returns an {@link module:engine/view/element~Element}.
  587. * The result of the function will be inserted into the view.
  588. *
  589. * The converter automatically consumes the corresponding value from the consumables list, stops the event (see
  590. * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}) and binds the model and view elements.
  591. *
  592. * downcastDispatcher.on(
  593. * 'insert:myElem',
  594. * insertElement( ( modelItem, viewWriter ) => {
  595. * const text = viewWriter.createText( 'myText' );
  596. * const myElem = viewWriter.createElement( 'myElem', { myAttr: 'my-' + modelItem.getAttribute( 'myAttr' ) }, text );
  597. *
  598. * // Do something fancy with `myElem` using `modelItem` or other parameters.
  599. *
  600. * return myElem;
  601. * }
  602. * ) );
  603. *
  604. * @protected
  605. * @param {Function} elementCreator Function returning a view element, which will be inserted.
  606. * @returns {Function} Insert element event converter.
  607. */
  608. export function insertElement( elementCreator ) {
  609. return ( evt, data, conversionApi ) => {
  610. const viewElement = elementCreator( data.item, conversionApi.writer );
  611. if ( !viewElement ) {
  612. return;
  613. }
  614. if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
  615. return;
  616. }
  617. const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
  618. conversionApi.mapper.bindElements( data.item, viewElement );
  619. conversionApi.writer.insert( viewPosition, viewElement );
  620. };
  621. }
  622. /**
  623. * Function factory that creates a converter which converts marker adding change to the
  624. * {@link module:engine/view/uielement~UIElement view UI element}.
  625. *
  626. * The view UI element that will be added to the view depends on the passed parameter. See {@link ~insertElement}.
  627. * In case of a non-collapsed range, the UI element will not wrap nodes but separate elements will be placed at the beginning
  628. * and at the end of the range.
  629. *
  630. * This converter binds created UI elements with the marker name using {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}.
  631. *
  632. * @protected
  633. * @param {module:engine/view/uielement~UIElement|Function} elementCreator A view UI element or a function returning the view element
  634. * that will be inserted.
  635. * @returns {Function} Insert element event converter.
  636. */
  637. export function insertUIElement( elementCreator ) {
  638. return ( evt, data, conversionApi ) => {
  639. // Create two view elements. One will be inserted at the beginning of marker, one at the end.
  640. // If marker is collapsed, only "opening" element will be inserted.
  641. data.isOpening = true;
  642. const viewStartElement = elementCreator( data, conversionApi.writer );
  643. data.isOpening = false;
  644. const viewEndElement = elementCreator( data, conversionApi.writer );
  645. if ( !viewStartElement || !viewEndElement ) {
  646. return;
  647. }
  648. const markerRange = data.markerRange;
  649. // Marker that is collapsed has consumable build differently that non-collapsed one.
  650. // For more information see `addMarker` event description.
  651. // If marker's range is collapsed - check if it can be consumed.
  652. if ( markerRange.isCollapsed && !conversionApi.consumable.consume( markerRange, evt.name ) ) {
  653. return;
  654. }
  655. // If marker's range is not collapsed - consume all items inside.
  656. for ( const value of markerRange ) {
  657. if ( !conversionApi.consumable.consume( value.item, evt.name ) ) {
  658. return;
  659. }
  660. }
  661. const mapper = conversionApi.mapper;
  662. const viewWriter = conversionApi.writer;
  663. // Add "opening" element.
  664. viewWriter.insert( mapper.toViewPosition( markerRange.start ), viewStartElement );
  665. conversionApi.mapper.bindElementToMarker( viewStartElement, data.markerName );
  666. // Add "closing" element only if range is not collapsed.
  667. if ( !markerRange.isCollapsed ) {
  668. viewWriter.insert( mapper.toViewPosition( markerRange.end ), viewEndElement );
  669. conversionApi.mapper.bindElementToMarker( viewEndElement, data.markerName );
  670. }
  671. evt.stop();
  672. };
  673. }
  674. // Function factory that returns a default downcast converter for removing a {@link module:engine/view/uielement~UIElement UI element}
  675. // basing on marker remove change.
  676. //
  677. // This converter unbinds elements from the marker name.
  678. //
  679. // @returns {Function} Removed UI element converter.
  680. function removeUIElement() {
  681. return ( evt, data, conversionApi ) => {
  682. const elements = conversionApi.mapper.markerNameToElements( data.markerName );
  683. if ( !elements ) {
  684. return;
  685. }
  686. for ( const element of elements ) {
  687. conversionApi.mapper.unbindElementFromMarkerName( element, data.markerName );
  688. conversionApi.writer.clear( conversionApi.writer.createRangeOn( element ), element );
  689. }
  690. conversionApi.writer.clearClonedElementsGroup( data.markerName );
  691. evt.stop();
  692. };
  693. }
  694. // Function factory that creates a converter which converts set/change/remove attribute changes from the model to the view.
  695. //
  696. // Attributes from the model are converted to the view element attributes in the view. You may provide a custom function to generate
  697. // a key-value attribute pair to add/change/remove. If not provided, model attributes will be converted to view element
  698. // attributes on a one-to-one basis.
  699. //
  700. // *Note:** The provided attribute creator should always return the same `key` for a given attribute from the model.
  701. //
  702. // The converter automatically consumes the corresponding value from the consumables list and stops the event (see
  703. // {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}).
  704. //
  705. // modelDispatcher.on( 'attribute:customAttr:myElem', changeAttribute( ( value, data ) => {
  706. // // Change attribute key from `customAttr` to `class` in the view.
  707. // const key = 'class';
  708. // let value = data.attributeNewValue;
  709. //
  710. // // Force attribute value to 'empty' if the model element is empty.
  711. // if ( data.item.childCount === 0 ) {
  712. // value = 'empty';
  713. // }
  714. //
  715. // // Return the key-value pair.
  716. // return { key, value };
  717. // } ) );
  718. //
  719. // @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
  720. // represent the attribute key and attribute value to be set on a {@link module:engine/view/element~Element view element}.
  721. // The function is passed the model attribute value as the first parameter and additional data about the change as the second parameter.
  722. // @returns {Function} Set/change attribute converter.
  723. function changeAttribute( attributeCreator ) {
  724. return ( evt, data, conversionApi ) => {
  725. const oldAttribute = attributeCreator( data.attributeOldValue, data );
  726. const newAttribute = attributeCreator( data.attributeNewValue, data );
  727. if ( !oldAttribute && !newAttribute ) {
  728. return;
  729. }
  730. if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
  731. return;
  732. }
  733. const viewElement = conversionApi.mapper.toViewElement( data.item );
  734. const viewWriter = conversionApi.writer;
  735. // If model item cannot be mapped to a view element, it means item is not an `Element` instance but a `TextProxy` node.
  736. // Only elements can have attributes in a view so do not proceed for anything else (#1587).
  737. if ( !viewElement ) {
  738. /**
  739. * This error occurs when a {@link module:engine/model/textproxy~TextProxy text node's} attribute is to be downcasted
  740. * by {@link module:engine/conversion/conversion~Conversion#attributeToAttribute `Attribute to Attribute converter`}.
  741. * In most cases it is caused by converters misconfiguration when only "generic" converter is defined:
  742. *
  743. * editor.conversion.for( 'downcast' ).attributeToAttribute( {
  744. * model: 'attribute-name',
  745. * view: 'attribute-name'
  746. * } ) );
  747. *
  748. * and given attribute is used on text node, for example:
  749. *
  750. * model.change( writer => {
  751. * writer.insertText( 'Foo', { 'attribute-name': 'bar' }, parent, 0 );
  752. * } );
  753. *
  754. * In such cases, to convert the same attribute for both {@link module:engine/model/element~Element}
  755. * and {@link module:engine/model/textproxy~TextProxy `Text`} nodes, text specific
  756. * {@link module:engine/conversion/conversion~Conversion#attributeToElement `Attribute to Element converter`}
  757. * with higher {@link module:utils/priorities~PriorityString priority} must also be defined:
  758. *
  759. * editor.conversion.for( 'downcast' ).attributeToElement( {
  760. * model: {
  761. * key: 'attribute-name',
  762. * name: '$text'
  763. * },
  764. * view: ( value, writer ) => {
  765. * return writer.createAttributeElement( 'span', { 'attribute-name': value } );
  766. * },
  767. * converterPriority: 'high'
  768. * } ) );
  769. *
  770. * @error conversion-attribute-to-attribute-on-text
  771. */
  772. throw new CKEditorError(
  773. 'conversion-attribute-to-attribute-on-text: ' +
  774. 'Trying to convert text node\'s attribute with attribute-to-attribute converter.',
  775. [ data, conversionApi ]
  776. );
  777. }
  778. // First remove the old attribute if there was one.
  779. if ( data.attributeOldValue !== null && oldAttribute ) {
  780. if ( oldAttribute.key == 'class' ) {
  781. const classes = Array.isArray( oldAttribute.value ) ? oldAttribute.value : [ oldAttribute.value ];
  782. for ( const className of classes ) {
  783. viewWriter.removeClass( className, viewElement );
  784. }
  785. } else if ( oldAttribute.key == 'style' ) {
  786. const keys = Object.keys( oldAttribute.value );
  787. for ( const key of keys ) {
  788. viewWriter.removeStyle( key, viewElement );
  789. }
  790. } else {
  791. viewWriter.removeAttribute( oldAttribute.key, viewElement );
  792. }
  793. }
  794. // Then set the new attribute.
  795. if ( data.attributeNewValue !== null && newAttribute ) {
  796. if ( newAttribute.key == 'class' ) {
  797. const classes = Array.isArray( newAttribute.value ) ? newAttribute.value : [ newAttribute.value ];
  798. for ( const className of classes ) {
  799. viewWriter.addClass( className, viewElement );
  800. }
  801. } else if ( newAttribute.key == 'style' ) {
  802. const keys = Object.keys( newAttribute.value );
  803. for ( const key of keys ) {
  804. viewWriter.setStyle( key, newAttribute.value[ key ], viewElement );
  805. }
  806. } else {
  807. viewWriter.setAttribute( newAttribute.key, newAttribute.value, viewElement );
  808. }
  809. }
  810. };
  811. }
  812. // Function factory that creates a converter which converts the text inside marker's range. The converter wraps the text with
  813. // {@link module:engine/view/attributeelement~AttributeElement} created from the provided descriptor.
  814. // See {link module:engine/conversion/downcasthelpers~createViewElementFromHighlightDescriptor}.
  815. //
  816. // It can also be used to convert the selection that is inside a marker. In that case, an empty attribute element will be
  817. // created and the selection will be put inside it.
  818. //
  819. // If the highlight descriptor does not provide the `priority` property, `10` will be used.
  820. //
  821. // If the highlight descriptor does not provide the `id` property, the name of the marker will be used.
  822. //
  823. // This converter binds the created {@link module:engine/view/attributeelement~AttributeElement attribute elemens} with the marker name
  824. // using the {@link module:engine/conversion/mapper~Mapper#bindElementToMarker} method.
  825. //
  826. // @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} highlightDescriptor
  827. // @returns {Function}
  828. function highlightText( highlightDescriptor ) {
  829. return ( evt, data, conversionApi ) => {
  830. if ( !data.item ) {
  831. return;
  832. }
  833. if ( !( data.item instanceof ModelSelection || data.item instanceof DocumentSelection ) && !data.item.is( 'textProxy' ) ) {
  834. return;
  835. }
  836. const descriptor = prepareDescriptor( highlightDescriptor, data, conversionApi );
  837. if ( !descriptor ) {
  838. return;
  839. }
  840. if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
  841. return;
  842. }
  843. const viewWriter = conversionApi.writer;
  844. const viewElement = createViewElementFromHighlightDescriptor( viewWriter, descriptor );
  845. const viewSelection = viewWriter.document.selection;
  846. if ( data.item instanceof ModelSelection || data.item instanceof DocumentSelection ) {
  847. viewWriter.wrap( viewSelection.getFirstRange(), viewElement, viewSelection );
  848. } else {
  849. const viewRange = conversionApi.mapper.toViewRange( data.range );
  850. const rangeAfterWrap = viewWriter.wrap( viewRange, viewElement );
  851. for ( const element of rangeAfterWrap.getItems() ) {
  852. if ( element.is( 'attributeElement' ) && element.isSimilar( viewElement ) ) {
  853. conversionApi.mapper.bindElementToMarker( element, data.markerName );
  854. // One attribute element is enough, because all of them are bound together by the view writer.
  855. // Mapper uses this binding to get all the elements no matter how many of them are registered in the mapper.
  856. break;
  857. }
  858. }
  859. }
  860. };
  861. }
  862. // Converter function factory. It creates a function which applies the marker's highlight to an element inside the marker's range.
  863. //
  864. // The converter checks if an element has the `addHighlight` function stored as a
  865. // {@link module:engine/view/element~Element#_setCustomProperty custom property} and, if so, uses it to apply the highlight.
  866. // In such case the converter will consume all element's children, assuming that they were handled by the element itself.
  867. //
  868. // When the `addHighlight` custom property is not present, the element is not converted in any special way.
  869. // This means that converters will proceed to convert the element's child nodes.
  870. //
  871. // If the highlight descriptor does not provide the `priority` property, `10` will be used.
  872. //
  873. // If the highlight descriptor does not provide the `id` property, the name of the marker will be used.
  874. //
  875. // This converter binds altered {@link module:engine/view/containerelement~ContainerElement container elements} with the marker name using
  876. // the {@link module:engine/conversion/mapper~Mapper#bindElementToMarker} method.
  877. //
  878. // @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} highlightDescriptor
  879. // @returns {Function}
  880. function highlightElement( highlightDescriptor ) {
  881. return ( evt, data, conversionApi ) => {
  882. if ( !data.item ) {
  883. return;
  884. }
  885. if ( !( data.item instanceof ModelElement ) ) {
  886. return;
  887. }
  888. const descriptor = prepareDescriptor( highlightDescriptor, data, conversionApi );
  889. if ( !descriptor ) {
  890. return;
  891. }
  892. if ( !conversionApi.consumable.test( data.item, evt.name ) ) {
  893. return;
  894. }
  895. const viewElement = conversionApi.mapper.toViewElement( data.item );
  896. if ( viewElement && viewElement.getCustomProperty( 'addHighlight' ) ) {
  897. // Consume element itself.
  898. conversionApi.consumable.consume( data.item, evt.name );
  899. // Consume all children nodes.
  900. for ( const value of ModelRange._createIn( data.item ) ) {
  901. conversionApi.consumable.consume( value.item, evt.name );
  902. }
  903. viewElement.getCustomProperty( 'addHighlight' )( viewElement, descriptor, conversionApi.writer );
  904. conversionApi.mapper.bindElementToMarker( viewElement, data.markerName );
  905. }
  906. };
  907. }
  908. // Function factory that creates a converter which converts the removing model marker to the view.
  909. //
  910. // Both text nodes and elements are handled by this converter but they are handled a bit differently.
  911. //
  912. // Text nodes are unwrapped using the {@link module:engine/view/attributeelement~AttributeElement attribute element} created from the
  913. // provided highlight descriptor. See {link module:engine/conversion/downcasthelpers~HighlightDescriptor}.
  914. //
  915. // For elements, the converter checks if an element has the `removeHighlight` function stored as a
  916. // {@link module:engine/view/element~Element#_setCustomProperty custom property}. If so, it uses it to remove the highlight.
  917. // In such case, the children of that element will not be converted.
  918. //
  919. // When `removeHighlight` is not present, the element is not converted in any special way.
  920. // The converter will proceed to convert the element's child nodes instead.
  921. //
  922. // If the highlight descriptor does not provide the `priority` property, `10` will be used.
  923. //
  924. // If the highlight descriptor does not provide the `id` property, the name of the marker will be used.
  925. //
  926. // This converter unbinds elements from the marker name.
  927. //
  928. // @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} highlightDescriptor
  929. // @returns {Function}
  930. function removeHighlight( highlightDescriptor ) {
  931. return ( evt, data, conversionApi ) => {
  932. // This conversion makes sense only for non-collapsed range.
  933. if ( data.markerRange.isCollapsed ) {
  934. return;
  935. }
  936. const descriptor = prepareDescriptor( highlightDescriptor, data, conversionApi );
  937. if ( !descriptor ) {
  938. return;
  939. }
  940. // View element that will be used to unwrap `AttributeElement`s.
  941. const viewHighlightElement = createViewElementFromHighlightDescriptor( conversionApi.writer, descriptor );
  942. // Get all elements bound with given marker name.
  943. const elements = conversionApi.mapper.markerNameToElements( data.markerName );
  944. if ( !elements ) {
  945. return;
  946. }
  947. for ( const element of elements ) {
  948. conversionApi.mapper.unbindElementFromMarkerName( element, data.markerName );
  949. if ( element.is( 'attributeElement' ) ) {
  950. conversionApi.writer.unwrap( conversionApi.writer.createRangeOn( element ), viewHighlightElement );
  951. } else {
  952. // if element.is( 'containerElement' ).
  953. element.getCustomProperty( 'removeHighlight' )( element, descriptor.id, conversionApi.writer );
  954. }
  955. }
  956. conversionApi.writer.clearClonedElementsGroup( data.markerName );
  957. evt.stop();
  958. };
  959. }
  960. // Model element to view element conversion helper.
  961. //
  962. // See {@link ~DowncastHelpers#elementToElement `.elementToElement()` downcast helper} for examples.
  963. //
  964. // @param {Object} config Conversion configuration.
  965. // @param {String} config.model The name of the model element to convert.
  966. // @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function
  967. // that takes the model element and {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer}
  968. // as parameters and returns a view container element.
  969. // @returns {Function} Conversion helper.
  970. function downcastElementToElement( config ) {
  971. config = cloneDeep( config );
  972. config.view = normalizeToElementConfig( config.view, 'container' );
  973. return dispatcher => {
  974. dispatcher.on( 'insert:' + config.model, insertElement( config.view ), { priority: config.converterPriority || 'normal' } );
  975. };
  976. }
  977. // Model attribute to view element conversion helper.
  978. //
  979. // See {@link ~DowncastHelpers#attributeToElement `.attributeToElement()` downcast helper} for examples.
  980. //
  981. // @param {Object} config Conversion configuration.
  982. // @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values }` object. `values` is an array
  983. // of `String`s with possible values if the model attribute is an enumerable.
  984. // @param {module:engine/view/elementdefinition~ElementDefinition|Function|Object} config.view A view element definition or a function
  985. // that takes the model attribute value and {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer}
  986. // as parameters and returns a view attribute element. If `config.model.values` is
  987. // given, `config.view` should be an object assigning values from `config.model.values` to view element definitions or functions.
  988. // @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
  989. // @returns {Function} Conversion helper.
  990. function downcastAttributeToElement( config ) {
  991. config = cloneDeep( config );
  992. const modelKey = config.model.key ? config.model.key : config.model;
  993. let eventName = 'attribute:' + modelKey;
  994. if ( config.model.name ) {
  995. eventName += ':' + config.model.name;
  996. }
  997. if ( config.model.values ) {
  998. for ( const modelValue of config.model.values ) {
  999. config.view[ modelValue ] = normalizeToElementConfig( config.view[ modelValue ], 'attribute' );
  1000. }
  1001. } else {
  1002. config.view = normalizeToElementConfig( config.view, 'attribute' );
  1003. }
  1004. const elementCreator = getFromAttributeCreator( config );
  1005. return dispatcher => {
  1006. dispatcher.on( eventName, wrap( elementCreator ), { priority: config.converterPriority || 'normal' } );
  1007. };
  1008. }
  1009. // Model attribute to view attribute conversion helper.
  1010. //
  1011. // See {@link ~DowncastHelpers#attributeToAttribute `.attributeToAttribute()` downcast helper} for examples.
  1012. //
  1013. // @param {Object} config Conversion configuration.
  1014. // @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values, [ name ] }` object describing
  1015. // the attribute key, possible values and, optionally, an element name to convert from.
  1016. // @param {String|Object|Function} config.view A view attribute key, or a `{ key, value }` object or a function that takes
  1017. // the model attribute value and returns a `{ key, value }` object. If `key` is `'class'`, `value` can be a `String` or an
  1018. // array of `String`s. If `key` is `'style'`, `value` is an object with key-value pairs. In other cases, `value` is a `String`.
  1019. // If `config.model.values` is set, `config.view` should be an object assigning values from `config.model.values` to
  1020. // `{ key, value }` objects or a functions.
  1021. // @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
  1022. // @returns {Function} Conversion helper.
  1023. function downcastAttributeToAttribute( config ) {
  1024. config = cloneDeep( config );
  1025. const modelKey = config.model.key ? config.model.key : config.model;
  1026. let eventName = 'attribute:' + modelKey;
  1027. if ( config.model.name ) {
  1028. eventName += ':' + config.model.name;
  1029. }
  1030. if ( config.model.values ) {
  1031. for ( const modelValue of config.model.values ) {
  1032. config.view[ modelValue ] = normalizeToAttributeConfig( config.view[ modelValue ] );
  1033. }
  1034. } else {
  1035. config.view = normalizeToAttributeConfig( config.view );
  1036. }
  1037. const elementCreator = getFromAttributeCreator( config );
  1038. return dispatcher => {
  1039. dispatcher.on( eventName, changeAttribute( elementCreator ), { priority: config.converterPriority || 'normal' } );
  1040. };
  1041. }
  1042. // Model marker to view element conversion helper.
  1043. //
  1044. // See {@link ~DowncastHelpers#markerToElement `.markerToElement()` downcast helper} for examples.
  1045. //
  1046. // @param {Object} config Conversion configuration.
  1047. // @param {String} config.model The name of the model marker (or model marker group) to convert.
  1048. // @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function
  1049. // that takes the model marker data as a parameter and returns a view UI element.
  1050. // @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
  1051. // @returns {Function} Conversion helper.
  1052. function downcastMarkerToElement( config ) {
  1053. config = cloneDeep( config );
  1054. config.view = normalizeToElementConfig( config.view, 'ui' );
  1055. return dispatcher => {
  1056. dispatcher.on( 'addMarker:' + config.model, insertUIElement( config.view ), { priority: config.converterPriority || 'normal' } );
  1057. dispatcher.on( 'removeMarker:' + config.model, removeUIElement( config.view ), { priority: config.converterPriority || 'normal' } );
  1058. };
  1059. }
  1060. // Model marker to highlight conversion helper.
  1061. //
  1062. // See {@link ~DowncastHelpers#markerToElement `.markerToElement()` downcast helper} for examples.
  1063. //
  1064. // @param {Object} config Conversion configuration.
  1065. // @param {String} config.model The name of the model marker (or model marker group) to convert.
  1066. // @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} config.view A highlight descriptor
  1067. // that will be used for highlighting or a function that takes the model marker data as a parameter and returns a highlight descriptor.
  1068. // @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
  1069. // @returns {Function} Conversion helper.
  1070. function downcastMarkerToHighlight( config ) {
  1071. return dispatcher => {
  1072. dispatcher.on( 'addMarker:' + config.model, highlightText( config.view ), { priority: config.converterPriority || 'normal' } );
  1073. dispatcher.on( 'addMarker:' + config.model, highlightElement( config.view ), { priority: config.converterPriority || 'normal' } );
  1074. dispatcher.on( 'removeMarker:' + config.model, removeHighlight( config.view ), { priority: config.converterPriority || 'normal' } );
  1075. };
  1076. }
  1077. // Takes `config.view`, and if it is an {@link module:engine/view/elementdefinition~ElementDefinition}, converts it
  1078. // to a function (because lower level converters accept only element creator functions).
  1079. //
  1080. // @param {module:engine/view/elementdefinition~ElementDefinition|Function} view View configuration.
  1081. // @param {'container'|'attribute'|'ui'} viewElementType View element type to create.
  1082. // @returns {Function} Element creator function to use in lower level converters.
  1083. function normalizeToElementConfig( view, viewElementType ) {
  1084. if ( typeof view == 'function' ) {
  1085. // If `view` is already a function, don't do anything.
  1086. return view;
  1087. }
  1088. return ( modelData, viewWriter ) => createViewElementFromDefinition( view, viewWriter, viewElementType );
  1089. }
  1090. // Creates a view element instance from the provided {@link module:engine/view/elementdefinition~ElementDefinition} and class.
  1091. //
  1092. // @param {module:engine/view/elementdefinition~ElementDefinition} viewElementDefinition
  1093. // @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
  1094. // @param {'container'|'attribute'|'ui'} viewElementType
  1095. // @returns {module:engine/view/element~Element}
  1096. function createViewElementFromDefinition( viewElementDefinition, viewWriter, viewElementType ) {
  1097. if ( typeof viewElementDefinition == 'string' ) {
  1098. // If `viewElementDefinition` is given as a `String`, normalize it to an object with `name` property.
  1099. viewElementDefinition = { name: viewElementDefinition };
  1100. }
  1101. let element;
  1102. const attributes = Object.assign( {}, viewElementDefinition.attributes );
  1103. if ( viewElementType == 'container' ) {
  1104. element = viewWriter.createContainerElement( viewElementDefinition.name, attributes );
  1105. } else if ( viewElementType == 'attribute' ) {
  1106. const options = {
  1107. priority: viewElementDefinition.priority || ViewAttributeElement.DEFAULT_PRIORITY
  1108. };
  1109. element = viewWriter.createAttributeElement( viewElementDefinition.name, attributes, options );
  1110. } else {
  1111. // 'ui'.
  1112. element = viewWriter.createUIElement( viewElementDefinition.name, attributes );
  1113. }
  1114. if ( viewElementDefinition.styles ) {
  1115. const keys = Object.keys( viewElementDefinition.styles );
  1116. for ( const key of keys ) {
  1117. viewWriter.setStyle( key, viewElementDefinition.styles[ key ], element );
  1118. }
  1119. }
  1120. if ( viewElementDefinition.classes ) {
  1121. const classes = viewElementDefinition.classes;
  1122. if ( typeof classes == 'string' ) {
  1123. viewWriter.addClass( classes, element );
  1124. } else {
  1125. for ( const className of classes ) {
  1126. viewWriter.addClass( className, element );
  1127. }
  1128. }
  1129. }
  1130. return element;
  1131. }
  1132. function getFromAttributeCreator( config ) {
  1133. if ( config.model.values ) {
  1134. return ( modelAttributeValue, viewWriter ) => {
  1135. const view = config.view[ modelAttributeValue ];
  1136. if ( view ) {
  1137. return view( modelAttributeValue, viewWriter );
  1138. }
  1139. return null;
  1140. };
  1141. } else {
  1142. return config.view;
  1143. }
  1144. }
  1145. // Takes the configuration, adds default parameters if they do not exist and normalizes other parameters to be used in downcast converters
  1146. // for generating a view attribute.
  1147. //
  1148. // @param {Object} view View configuration.
  1149. function normalizeToAttributeConfig( view ) {
  1150. if ( typeof view == 'string' ) {
  1151. return modelAttributeValue => ( { key: view, value: modelAttributeValue } );
  1152. } else if ( typeof view == 'object' ) {
  1153. // { key, value, ... }
  1154. if ( view.value ) {
  1155. return () => view;
  1156. }
  1157. // { key, ... }
  1158. else {
  1159. return modelAttributeValue => ( { key: view.key, value: modelAttributeValue } );
  1160. }
  1161. } else {
  1162. // function.
  1163. return view;
  1164. }
  1165. }
  1166. // Helper function for `highlight`. Prepares the actual descriptor object using value passed to the converter.
  1167. function prepareDescriptor( highlightDescriptor, data, conversionApi ) {
  1168. // If passed descriptor is a creator function, call it. If not, just use passed value.
  1169. const descriptor = typeof highlightDescriptor == 'function' ?
  1170. highlightDescriptor( data, conversionApi ) :
  1171. highlightDescriptor;
  1172. if ( !descriptor ) {
  1173. return null;
  1174. }
  1175. // Apply default descriptor priority.
  1176. if ( !descriptor.priority ) {
  1177. descriptor.priority = 10;
  1178. }
  1179. // Default descriptor id is marker name.
  1180. if ( !descriptor.id ) {
  1181. descriptor.id = data.markerName;
  1182. }
  1183. return descriptor;
  1184. }
  1185. /**
  1186. * An object describing how the marker highlight should be represented in the view.
  1187. *
  1188. * Each text node contained in a highlighted range will be wrapped in a `<span>`
  1189. * {@link module:engine/view/attributeelement~AttributeElement view attribute element} with CSS class(es), attributes and a priority
  1190. * described by this object.
  1191. *
  1192. * Additionally, each {@link module:engine/view/containerelement~ContainerElement container element} can handle displaying the highlight
  1193. * separately by providing the `addHighlight` and `removeHighlight` custom properties. In this case:
  1194. *
  1195. * * The `HighlightDescriptor` object is passed to the `addHighlight` function upon conversion and should be used to apply the highlight to
  1196. * the element.
  1197. * * The descriptor `id` is passed to the `removeHighlight` function upon conversion and should be used to remove the highlight with the
  1198. * given ID from the element.
  1199. *
  1200. * @typedef {Object} module:engine/conversion/downcasthelpers~HighlightDescriptor
  1201. *
  1202. * @property {String|Array.<String>} classes A CSS class or an array of classes to set. If the descriptor is used to
  1203. * create an {@link module:engine/view/attributeelement~AttributeElement attribute element} over text nodes, these classes will be set
  1204. * on that attribute element. If the descriptor is applied to an element, usually these classes will be set on that element, however,
  1205. * this depends on how the element converts the descriptor.
  1206. *
  1207. * @property {String} [id] Descriptor identifier. If not provided, it defaults to the converted marker's name.
  1208. *
  1209. * @property {Number} [priority] Descriptor priority. If not provided, it defaults to `10`. If the descriptor is used to create
  1210. * an {@link module:engine/view/attributeelement~AttributeElement attribute element}, it will be that element's
  1211. * {@link module:engine/view/attributeelement~AttributeElement#priority priority}. If the descriptor is applied to an element,
  1212. * the priority will be used to determine which descriptor is more important.
  1213. *
  1214. * @property {Object} [attributes] Attributes to set. If the descriptor is used to create
  1215. * an {@link module:engine/view/attributeelement~AttributeElement attribute element} over text nodes, these attributes will be set on that
  1216. * attribute element. If the descriptor is applied to an element, usually these attributes will be set on that element, however,
  1217. * this depends on how the element converts the descriptor.
  1218. */