downcasthelpers.js 113 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676
  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. /* globals console */
  6. import EditingController from '../../src/controller/editingcontroller';
  7. import Model from '../../src/model/model';
  8. import ModelElement from '../../src/model/element';
  9. import ModelText from '../../src/model/text';
  10. import ViewElement from '../../src/view/element';
  11. import ViewAttributeElement from '../../src/view/attributeelement';
  12. import ViewContainerElement from '../../src/view/containerelement';
  13. import ViewUIElement from '../../src/view/uielement';
  14. import ViewText from '../../src/view/text';
  15. import ViewDocument from '../../src/view/document';
  16. import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
  17. import DowncastHelpers, {
  18. clearAttributes,
  19. convertCollapsedSelection,
  20. convertRangeSelection,
  21. createViewElementFromHighlightDescriptor,
  22. insertText
  23. } from '../../src/conversion/downcasthelpers';
  24. import Mapper from '../../src/conversion/mapper';
  25. import DowncastDispatcher from '../../src/conversion/downcastdispatcher';
  26. import { stringify as stringifyView } from '../../src/dev-utils/view';
  27. import View from '../../src/view/view';
  28. import createViewRoot from '../view/_utils/createroot';
  29. import { setData as setModelData } from '../../src/dev-utils/model';
  30. import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
  31. import { StylesProcessor } from '../../src/view/stylesmap';
  32. import DowncastWriter from '../../src/view/downcastwriter';
  33. function insertBazSlot( writer, modelRoot ) {
  34. const slot = writer.createElement( 'slot' );
  35. const paragraph = writer.createElement( 'paragraph' );
  36. writer.insertText( 'baz', paragraph, 0 );
  37. writer.insert( paragraph, slot, 0 );
  38. writer.insert( slot, modelRoot.getChild( 0 ), 'end' );
  39. }
  40. describe( 'DowncastHelpers', () => {
  41. let model, modelRoot, viewRoot, downcastHelpers, controller, modelRootStart;
  42. beforeEach( () => {
  43. model = new Model();
  44. const modelDoc = model.document;
  45. modelRoot = modelDoc.createRoot();
  46. controller = new EditingController( model, new StylesProcessor() );
  47. // Set name of view root the same as dom root.
  48. // This is a mock of attaching view root to dom root.
  49. controller.view.document.getRoot()._name = 'div';
  50. viewRoot = controller.view.document.getRoot();
  51. downcastHelpers = new DowncastHelpers( [ controller.downcastDispatcher ] );
  52. modelRootStart = model.createPositionAt( modelRoot, 0 );
  53. } );
  54. describe( 'elementToElement()', () => {
  55. it( 'should be chainable', () => {
  56. expect( downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } ) ).to.equal( downcastHelpers );
  57. } );
  58. it( 'config.view is a string', () => {
  59. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  60. model.change( writer => {
  61. writer.insertElement( 'paragraph', modelRoot, 0 );
  62. } );
  63. expectResult( '<p></p>' );
  64. } );
  65. it( 'can be overwritten using converterPriority', () => {
  66. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  67. downcastHelpers.elementToElement( { model: 'paragraph', view: 'foo', converterPriority: 'high' } );
  68. model.change( writer => {
  69. writer.insertElement( 'paragraph', modelRoot, 0 );
  70. } );
  71. expectResult( '<foo></foo>' );
  72. } );
  73. it( 'config.view is a view element definition', () => {
  74. downcastHelpers.elementToElement( {
  75. model: 'fancyParagraph',
  76. view: {
  77. name: 'p',
  78. classes: 'fancy'
  79. }
  80. } );
  81. model.change( writer => {
  82. writer.insertElement( 'fancyParagraph', modelRoot, 0 );
  83. } );
  84. expectResult( '<p class="fancy"></p>' );
  85. } );
  86. it( 'config.view is a function', () => {
  87. downcastHelpers.elementToElement( {
  88. model: 'heading',
  89. view: ( modelElement, { writer } ) => writer.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) )
  90. } );
  91. model.change( writer => {
  92. writer.insertElement( 'heading', { level: 2 }, modelRoot, 0 );
  93. } );
  94. expectResult( '<h2></h2>' );
  95. } );
  96. describe( 'config.triggerBy', () => {
  97. describe( 'with simple block view structure', () => {
  98. beforeEach( () => {
  99. model.schema.register( 'simpleBlock', {
  100. allowIn: '$root',
  101. allowAttributes: [ 'toStyle', 'toClass' ]
  102. } );
  103. downcastHelpers.elementToElement( {
  104. model: 'simpleBlock',
  105. view: ( modelElement, { writer } ) => {
  106. return writer.createContainerElement( 'div', getViewAttributes( modelElement ) );
  107. },
  108. triggerBy: [
  109. 'attribute:toStyle:simpleBlock',
  110. 'attribute:toClass:simpleBlock'
  111. ]
  112. } );
  113. } );
  114. it( 'should convert on insert', () => {
  115. model.change( writer => {
  116. writer.insertElement( 'simpleBlock', modelRoot, 0 );
  117. } );
  118. expectResult( '<div></div>' );
  119. } );
  120. it( 'should convert on attribute set', () => {
  121. setModelData( model, '<simpleBlock></simpleBlock>' );
  122. model.change( writer => {
  123. writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) );
  124. } );
  125. expectResult( '<div style="display:block"></div>' );
  126. } );
  127. it( 'should convert on attribute change', () => {
  128. setModelData( model, '<simpleBlock toStyle="display:block"></simpleBlock>' );
  129. model.change( writer => {
  130. writer.setAttribute( 'toStyle', 'display:inline', modelRoot.getChild( 0 ) );
  131. } );
  132. expectResult( '<div style="display:inline"></div>' );
  133. } );
  134. it( 'should convert on attribute remove', () => {
  135. setModelData( model, '<simpleBlock toStyle="display:block"></simpleBlock>' );
  136. model.change( writer => {
  137. writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) );
  138. } );
  139. expectResult( '<div></div>' );
  140. } );
  141. it( 'should convert on one attribute add and other remove', () => {
  142. setModelData( model, '<simpleBlock toStyle="display:block"></simpleBlock>' );
  143. model.change( writer => {
  144. writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) );
  145. writer.setAttribute( 'toClass', true, modelRoot.getChild( 0 ) );
  146. } );
  147. expectResult( '<div class="is-classy"></div>' );
  148. } );
  149. it( 'should do nothing if non-triggerBy attribute has changed', () => {
  150. setModelData( model, '<simpleBlock></simpleBlock>' );
  151. model.change( writer => {
  152. writer.setAttribute( 'notTriggered', true, modelRoot.getChild( 0 ) );
  153. } );
  154. expectResult( '<div></div>' );
  155. } );
  156. } );
  157. describe( 'with complex view structure - no children allowed', () => {
  158. beforeEach( () => {
  159. model.schema.register( 'complex', {
  160. allowIn: '$root',
  161. allowAttributes: [ 'toStyle', 'toClass' ]
  162. } );
  163. downcastHelpers.elementToElement( {
  164. model: 'complex',
  165. view: ( modelElement, { writer } ) => {
  166. const outer = writer.createContainerElement( 'div', { class: 'complex-outer' } );
  167. const inner = writer.createContainerElement( 'div', getViewAttributes( modelElement ) );
  168. writer.insert( writer.createPositionAt( outer, 0 ), inner );
  169. return outer;
  170. },
  171. triggerBy: [
  172. 'attribute:toStyle:complex',
  173. 'attribute:toClass:complex'
  174. ]
  175. } );
  176. } );
  177. it( 'should convert on insert', () => {
  178. model.change( writer => {
  179. writer.insertElement( 'complex', modelRoot, 0 );
  180. } );
  181. expectResult( '<div class="complex-outer"><div></div></div>' );
  182. } );
  183. it( 'should convert on attribute set', () => {
  184. setModelData( model, '<complex></complex>' );
  185. model.change( writer => {
  186. writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) );
  187. } );
  188. expectResult( '<div class="complex-outer"><div style="display:block"></div></div>' );
  189. } );
  190. it( 'should convert on attribute change', () => {
  191. setModelData( model, '<complex toStyle="display:block"></complex>' );
  192. model.change( writer => {
  193. writer.setAttribute( 'toStyle', 'display:inline', modelRoot.getChild( 0 ) );
  194. } );
  195. expectResult( '<div class="complex-outer"><div style="display:inline"></div></div>' );
  196. } );
  197. it( 'should convert on attribute remove', () => {
  198. setModelData( model, '<complex toStyle="display:block"></complex>' );
  199. model.change( writer => {
  200. writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) );
  201. } );
  202. expectResult( '<div class="complex-outer"><div></div></div>' );
  203. } );
  204. it( 'should convert on one attribute add and other remove', () => {
  205. setModelData( model, '<complex toStyle="display:block"></complex>' );
  206. model.change( writer => {
  207. writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) );
  208. writer.setAttribute( 'toClass', true, modelRoot.getChild( 0 ) );
  209. } );
  210. expectResult( '<div class="complex-outer"><div class="is-classy"></div></div>' );
  211. } );
  212. it( 'should do nothing if non-triggerBy attribute has changed', () => {
  213. setModelData( model, '<complex></complex>' );
  214. model.change( writer => {
  215. writer.setAttribute( 'notTriggered', true, modelRoot.getChild( 0 ) );
  216. } );
  217. expectResult( '<div class="complex-outer"><div></div></div>' );
  218. } );
  219. } );
  220. describe( 'with complex view structure (without slots)', () => {
  221. beforeEach( () => {
  222. model.schema.register( 'complex', {
  223. allowIn: '$root',
  224. allowAttributes: [ 'toStyle', 'toClass' ]
  225. } );
  226. downcastHelpers.elementToElement( {
  227. model: 'complex',
  228. view: ( modelElement, { writer, mapper } ) => {
  229. const outer = writer.createContainerElement( 'c-outer' );
  230. const inner = writer.createContainerElement( 'c-inner', getViewAttributes( modelElement ) );
  231. writer.insert( writer.createPositionAt( outer, 0 ), inner );
  232. mapper.bindElements( modelElement, inner );
  233. return outer;
  234. },
  235. triggerBy: [
  236. 'attribute:toStyle:complex',
  237. 'attribute:toClass:complex'
  238. ]
  239. } );
  240. model.schema.register( 'paragraph', {
  241. inheritAllFrom: '$block',
  242. allowIn: 'complex'
  243. } );
  244. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  245. } );
  246. it( 'should convert on insert', () => {
  247. model.change( writer => {
  248. writer.insertElement( 'complex', modelRoot, 0 );
  249. } );
  250. expectResult( '<c-outer><c-inner></c-inner></c-outer>' );
  251. } );
  252. it( 'should convert on attribute set', () => {
  253. setModelData( model, '<complex></complex>' );
  254. model.change( writer => {
  255. writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) );
  256. } );
  257. expectResult( '<c-outer><c-inner style="display:block"></c-inner></c-outer>' );
  258. } );
  259. it( 'should convert on attribute remove', () => {
  260. setModelData( model, '<complex toStyle="display:block"></complex>' );
  261. model.change( writer => {
  262. writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) );
  263. } );
  264. expectResult( '<c-outer><c-inner></c-inner></c-outer>' );
  265. } );
  266. it( 'should convert on one attribute add and other remove', () => {
  267. setModelData( model, '<complex toStyle="display:block"></complex>' );
  268. model.change( writer => {
  269. writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) );
  270. writer.setAttribute( 'toClass', true, modelRoot.getChild( 0 ) );
  271. } );
  272. expectResult( '<c-outer><c-inner class="is-classy"></c-inner></c-outer>' );
  273. } );
  274. it( 'should do nothing if non-triggerBy attribute has changed', () => {
  275. setModelData( model, '<complex></complex>' );
  276. model.change( writer => {
  277. writer.setAttribute( 'notTriggered', true, modelRoot.getChild( 0 ) );
  278. } );
  279. expectResult( '<c-outer><c-inner></c-inner></c-outer>' );
  280. } );
  281. describe( 'memoization', () => {
  282. it( 'should create new element on re-converting element', () => {
  283. setModelData( model, '<complex></complex>' );
  284. const renderedView = viewRoot.getChild( 0 );
  285. model.change( writer => {
  286. writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) );
  287. } );
  288. const viewAfterReRender = viewRoot.getChild( 0 );
  289. expect( viewAfterReRender ).to.not.equal( renderedView );
  290. } );
  291. // Skipped, as it would require two-level mapping. See https://github.com/ckeditor/ckeditor5/issues/1589.
  292. it.skip( 'should not re-create child elements on re-converting element', () => {
  293. setModelData( model, '<complex><paragraph>Foo bar baz</paragraph></complex>' );
  294. expectResult( '<c-outer><c-inner><p>Foo bar baz</p></c-inner></c-outer>' );
  295. const renderedViewView = viewRoot.getChild( 0 ).getChild( 0 );
  296. model.change( writer => {
  297. writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) );
  298. } );
  299. const viewAfterReRender = viewRoot.getChild( 0 ).getChild( 0 );
  300. expect( viewAfterReRender ).to.equal( renderedViewView );
  301. } );
  302. } );
  303. } );
  304. describe( 'with complex view structure (slot conversion)', () => {
  305. beforeEach( () => {
  306. model.schema.register( 'complex', {
  307. allowIn: '$root',
  308. allowAttributes: [ 'classForMain', 'classForWrap', 'attributeToElement' ]
  309. } );
  310. downcastHelpers.elementToElement( {
  311. model: 'complex',
  312. view: ( modelElement, { writer, mapper } ) => {
  313. const classForMain = !!modelElement.getAttribute( 'classForMain' );
  314. const classForWrap = !!modelElement.getAttribute( 'classForWrap' );
  315. const attributeToElement = !!modelElement.getAttribute( 'attributeToElement' );
  316. const outer = writer.createContainerElement( 'div', {
  317. class: `complex-slots${ classForMain ? ' with-class' : '' }`
  318. } );
  319. const inner = writer.createContainerElement( 'div', {
  320. class: `slots${ classForWrap ? ' with-class' : '' }`
  321. } );
  322. if ( attributeToElement ) {
  323. const optional = writer.createEmptyElement( 'div', { class: 'optional' } );
  324. writer.insert( writer.createPositionAt( outer, 0 ), optional );
  325. }
  326. writer.insert( writer.createPositionAt( outer, 'end' ), inner );
  327. mapper.bindElements( modelElement, inner );
  328. for ( const slot of modelElement.getChildren() ) {
  329. const viewSlot = writer.createContainerElement( 'div', { class: 'slot' } );
  330. writer.insert( writer.createPositionAt( inner, slot.index ), viewSlot );
  331. mapper.bindSlotElements( slot, viewSlot );
  332. }
  333. return outer;
  334. },
  335. triggerBy: [
  336. 'attribute:classForMain:complex',
  337. 'attribute:classForWrap:complex',
  338. 'attribute:attributeToElement:complex',
  339. 'insert:slot',
  340. 'remove:slot'
  341. ]
  342. } );
  343. model.schema.register( 'slot', {
  344. allowIn: 'complex'
  345. } );
  346. model.schema.register( 'paragraph', {
  347. inheritAllFrom: '$block',
  348. allowIn: 'slot'
  349. } );
  350. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  351. } );
  352. it( 'should convert on insert', () => {
  353. model.change( writer => {
  354. writer.insertElement( 'complex', modelRoot, 0 );
  355. } );
  356. expectResult( '<div class="complex-slots"><div class="slots"></div></div>' );
  357. } );
  358. it( 'should convert on attribute set (main element)', () => {
  359. setModelData( model, '<complex></complex>' );
  360. model.change( writer => {
  361. writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) );
  362. } );
  363. expectResult( '<div class="complex-slots with-class"><div class="slots"></div></div>' );
  364. } );
  365. it( 'should convert on attribute set (other element)', () => {
  366. setModelData( model, '<complex></complex>' );
  367. model.change( writer => {
  368. writer.setAttribute( 'classForWrap', true, modelRoot.getChild( 0 ) );
  369. } );
  370. expectResult( '<div class="complex-slots"><div class="slots with-class"></div></div>' );
  371. } );
  372. it( 'should convert on attribute set (insert new view element)', () => {
  373. setModelData( model, '<complex></complex>' );
  374. model.change( writer => {
  375. writer.setAttribute( 'attributeToElement', true, modelRoot.getChild( 0 ) );
  376. } );
  377. expectResult( '<div class="complex-slots"><div class="optional"></div><div class="slots"></div></div>' );
  378. } );
  379. it( 'should convert element with slots', () => {
  380. setModelData( model,
  381. '<complex>' +
  382. '<slot><paragraph>foo</paragraph></slot>' +
  383. '<slot><paragraph>bar</paragraph></slot>' +
  384. '</complex>' );
  385. expectResult(
  386. '<div class="complex-slots">' +
  387. '<div class="slots">' +
  388. '<div class="slot"><p>foo</p></div>' +
  389. '<div class="slot"><p>bar</p></div>' +
  390. '</div>' +
  391. '</div>'
  392. );
  393. } );
  394. it( 'should convert element on adding slot', () => {
  395. setModelData( model,
  396. '<complex>' +
  397. '<slot><paragraph>foo</paragraph></slot>' +
  398. '<slot><paragraph>bar</paragraph></slot>' +
  399. '</complex>' );
  400. model.change( writer => {
  401. insertBazSlot( writer, modelRoot );
  402. } );
  403. expectResult(
  404. '<div class="complex-slots">' +
  405. '<div class="slots">' +
  406. '<div class="slot"><p>foo</p></div>' +
  407. '<div class="slot"><p>bar</p></div>' +
  408. '<div class="slot"><p>baz</p></div>' +
  409. '</div>' +
  410. '</div>'
  411. );
  412. } );
  413. it( 'should convert element on removing slot', () => {
  414. setModelData( model,
  415. '<complex>' +
  416. '<slot><paragraph>foo</paragraph></slot>' +
  417. '<slot><paragraph>bar</paragraph></slot>' +
  418. '</complex>' );
  419. model.change( writer => {
  420. writer.remove( modelRoot.getChild( 0 ).getChild( 0 ) );
  421. } );
  422. expectResult(
  423. '<div class="complex-slots">' +
  424. '<div class="slots">' +
  425. '<div class="slot"><p>bar</p></div>' +
  426. '</div>' +
  427. '</div>'
  428. );
  429. } );
  430. it( 'should convert element on multiple triggers (remove + insert)', () => {
  431. setModelData( model,
  432. '<complex>' +
  433. '<slot><paragraph>foo</paragraph></slot>' +
  434. '<slot><paragraph>bar</paragraph></slot>' +
  435. '</complex>' );
  436. model.change( writer => {
  437. writer.remove( modelRoot.getChild( 0 ).getChild( 0 ) );
  438. insertBazSlot( writer, modelRoot );
  439. } );
  440. expectResult(
  441. '<div class="complex-slots">' +
  442. '<div class="slots">' +
  443. '<div class="slot"><p>bar</p></div>' +
  444. '<div class="slot"><p>baz</p></div>' +
  445. '</div>' +
  446. '</div>'
  447. );
  448. } );
  449. it( 'should convert element on multiple triggers (remove + attribute)', () => {
  450. setModelData( model,
  451. '<complex>' +
  452. '<slot><paragraph>foo</paragraph></slot>' +
  453. '<slot><paragraph>bar</paragraph></slot>' +
  454. '</complex>' );
  455. model.change( writer => {
  456. writer.remove( modelRoot.getChild( 0 ).getChild( 0 ) );
  457. writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) );
  458. } );
  459. expectResult(
  460. '<div class="complex-slots with-class">' +
  461. '<div class="slots">' +
  462. '<div class="slot"><p>bar</p></div>' +
  463. '</div>' +
  464. '</div>'
  465. );
  466. } );
  467. it( 'should convert element on multiple triggers (insert + attribute)', () => {
  468. setModelData( model,
  469. '<complex>' +
  470. '<slot><paragraph>foo</paragraph></slot>' +
  471. '<slot><paragraph>bar</paragraph></slot>' +
  472. '</complex>' );
  473. model.change( writer => {
  474. insertBazSlot( writer, modelRoot );
  475. writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) );
  476. } );
  477. expectResult(
  478. '<div class="complex-slots with-class">' +
  479. '<div class="slots">' +
  480. '<div class="slot"><p>foo</p></div>' +
  481. '<div class="slot"><p>bar</p></div>' +
  482. '<div class="slot"><p>baz</p></div>' +
  483. '</div>' +
  484. '</div>'
  485. );
  486. } );
  487. describe( 'memoization', () => {
  488. it( 'should create new element on re-converting element', () => {
  489. setModelData( model, '<complex>' +
  490. '<slot><paragraph>foo</paragraph></slot>' +
  491. '<slot><paragraph>bar</paragraph></slot>' +
  492. '</complex>'
  493. );
  494. const complexView = viewRoot.getChild( 0 );
  495. model.change( writer => {
  496. writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) );
  497. } );
  498. const viewAfterReRender = viewRoot.getChild( 0 );
  499. expect( viewAfterReRender ).to.not.equal( complexView );
  500. } );
  501. it( 'should not re-create slot\'s child elements on re-converting main element (attribute changed)', () => {
  502. setModelData( model, '<complex>' +
  503. '<slot><paragraph>foo</paragraph></slot>' +
  504. '<slot><paragraph>bar</paragraph></slot>' +
  505. '</complex>'
  506. );
  507. const [ main, slotOne, slotOneChild, slotTwo, slotTwoChild ] = getNodes();
  508. model.change( writer => {
  509. writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) );
  510. } );
  511. const [ mainAfter, slotOneAfter, slotOneChildAfter, slotTwoAfter, slotTwoChildAfter ] = getNodes();
  512. expect( mainAfter, 'main view' ).to.not.equal( main );
  513. expect( slotOneAfter, 'first slot view' ).to.not.equal( slotOne );
  514. expect( slotTwoAfter, 'second slot view' ).to.not.equal( slotTwo );
  515. expect( slotOneChildAfter, 'first slot paragraph view' ).to.equal( slotOneChild );
  516. expect( slotTwoChildAfter, 'second slot paragraph view' ).to.equal( slotTwoChild );
  517. } );
  518. it( 'should not re-create slot\'s child elements on re-converting main element (slot added)', () => {
  519. setModelData( model, '<complex>' +
  520. '<slot><paragraph>foo</paragraph></slot>' +
  521. '<slot><paragraph>bar</paragraph></slot>' +
  522. '</complex>'
  523. );
  524. const [ main, slotOne, slotOneChild, slotTwo, slotTwoChild ] = getNodes();
  525. model.change( writer => {
  526. const slot = writer.createElement( 'slot' );
  527. const paragraph = writer.createElement( 'paragraph' );
  528. writer.insertText( 'baz', paragraph, 0 );
  529. writer.insert( paragraph, slot, 0 );
  530. writer.insert( slot, modelRoot.getChild( 0 ), 'end' );
  531. } );
  532. const [
  533. mainAfter,
  534. slotOneAfter, slotOneChildAfter,
  535. slotTwoAfter, slotTwoChildAfter,
  536. slot3, slot3Child
  537. ] = getNodes();
  538. expect( mainAfter, 'main view' ).to.not.equal( main );
  539. expect( slotOneAfter, 'first slot view' ).to.not.equal( slotOne );
  540. expect( slotTwoAfter, 'second slot view' ).to.not.equal( slotTwo );
  541. expect( slotOneChildAfter, 'first slot paragraph view' ).to.equal( slotOneChild );
  542. expect( slotTwoChildAfter, 'second slot paragraph view' ).to.equal( slotTwoChild );
  543. expect( slot3, 'third slot view' ).to.not.be.undefined;
  544. expect( slot3Child, 'third slot paragraph view' ).to.not.be.undefined;
  545. } );
  546. /**
  547. * Returns a generator that yields elements as [ mainView, slot1, childOfSlot1, slot2, childOfSlot2, ... ].
  548. */
  549. function* getNodes() {
  550. const main = viewRoot.getChild( 0 );
  551. yield main;
  552. const slotWrap = main.getChild( 0 );
  553. for ( const slot of slotWrap.getChildren() ) {
  554. const slotOneChild = slot.getChild( 0 );
  555. yield slot;
  556. yield slotOneChild;
  557. }
  558. }
  559. } );
  560. } );
  561. // Skipped, as it would require two-level mapping. See https://github.com/ckeditor/ckeditor5/issues/1589.
  562. describe.skip( 'with complex view structure (slot conversion atomic converters for some changes)', () => {
  563. beforeEach( () => {
  564. model.schema.register( 'complex', {
  565. allowIn: '$root',
  566. allowAttributes: [ 'classForMain', 'classForWrap', 'attributeToElement' ]
  567. } );
  568. function createViewSlot( slot, { writer, mapper } ) {
  569. const viewSlot = writer.createContainerElement( 'div', { class: 'slot' } );
  570. mapper.bindSlotElements( slot, viewSlot );
  571. return viewSlot;
  572. }
  573. downcastHelpers.elementToElement( {
  574. model: 'complex',
  575. view: ( modelElement, { writer, mapper, consumable } ) => {
  576. const classForMain = !!modelElement.getAttribute( 'classForMain' );
  577. const classForWrap = !!modelElement.getAttribute( 'classForWrap' );
  578. const attributeToElement = !!modelElement.getAttribute( 'attributeToElement' );
  579. const outer = writer.createContainerElement( 'div', {
  580. class: `complex-slots${ classForMain ? ' with-class' : '' }`
  581. } );
  582. const inner = writer.createContainerElement( 'div', {
  583. class: `slots${ classForWrap ? ' with-class' : '' }`
  584. } );
  585. if ( attributeToElement ) {
  586. const optional = writer.createEmptyElement( 'div', { class: 'optional' } );
  587. writer.insert( writer.createPositionAt( outer, 0 ), optional );
  588. }
  589. writer.insert( writer.createPositionAt( outer, 'end' ), inner );
  590. mapper.bindElements( modelElement, outer );
  591. mapper.bindElements( modelElement, inner );
  592. for ( const slot of modelElement.getChildren() ) {
  593. const viewSlot = createViewSlot( slot, { writer, mapper } );
  594. writer.insert( writer.createPositionAt( inner, slot.index ), viewSlot );
  595. consumable.consume( slot, 'insert' );
  596. }
  597. return outer;
  598. },
  599. triggerBy: [
  600. 'attribute:classForMain:complex',
  601. 'attribute:classForWrap:complex',
  602. 'attribute:attributeToElement:complex'
  603. // Contrary to the previous test - do not act on slot insert/remove: 'insert:slot', 'remove:slot'.
  604. ]
  605. } );
  606. downcastHelpers.elementToElement( {
  607. model: 'slot',
  608. view: createViewSlot
  609. } );
  610. model.schema.register( 'slot', {
  611. allowIn: 'complex'
  612. } );
  613. model.schema.register( 'paragraph', {
  614. inheritAllFrom: '$block',
  615. allowIn: 'slot'
  616. } );
  617. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  618. } );
  619. it( 'should convert on insert', () => {
  620. model.change( writer => {
  621. writer.insertElement( 'complex', modelRoot, 0 );
  622. } );
  623. expectResult( '<div class="complex-slots"><div class="slots"></div></div>' );
  624. } );
  625. // TODO: add memoization check - as this is need.
  626. it( 'should convert on attribute set (main element)', () => {
  627. setModelData( model, '<complex></complex>' );
  628. model.change( writer => {
  629. writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) );
  630. } );
  631. expectResult( '<div class="complex-slots with-class"><div class="slots"></div></div>' );
  632. } );
  633. it( 'should convert on attribute set (other element)', () => {
  634. setModelData( model, '<complex></complex>' );
  635. model.change( writer => {
  636. writer.setAttribute( 'classForWrap', true, modelRoot.getChild( 0 ) );
  637. } );
  638. expectResult( '<div class="complex-slots"><div class="slots with-class"></div></div>' );
  639. } );
  640. it( 'should convert on attribute set (insert new view element)', () => {
  641. setModelData( model, '<complex></complex>' );
  642. model.change( writer => {
  643. writer.setAttribute( 'attributeToElement', true, modelRoot.getChild( 0 ) );
  644. } );
  645. expectResult( '<div class="complex-slots"><div class="optional"></div><div class="slots"></div></div>' );
  646. } );
  647. it( 'should convert element with slots', () => {
  648. setModelData( model,
  649. '<complex>' +
  650. '<slot><paragraph>foo</paragraph></slot>' +
  651. '<slot><paragraph>bar</paragraph></slot>' +
  652. '</complex>' );
  653. expectResult(
  654. '<div class="complex-slots">' +
  655. '<div class="slots">' +
  656. '<div class="slot"><p>foo</p></div>' +
  657. '<div class="slot"><p>bar</p></div>' +
  658. '</div>' +
  659. '</div>'
  660. );
  661. } );
  662. it( 'should not convert element on adding slot', () => {
  663. setModelData( model,
  664. '<complex>' +
  665. '<slot><paragraph>foo</paragraph></slot>' +
  666. '<slot><paragraph>bar</paragraph></slot>' +
  667. '</complex>' );
  668. model.change( writer => {
  669. const slot = writer.createElement( 'slot' );
  670. const paragraph = writer.createElement( 'paragraph' );
  671. writer.insertText( 'baz', paragraph, 0 );
  672. writer.insert( paragraph, slot, 0 );
  673. writer.insert( slot, modelRoot.getChild( 0 ), 'end' );
  674. } );
  675. expectResult(
  676. '<div class="complex-slots">' +
  677. '<div class="slots">' +
  678. '<div class="slot"><p>foo</p></div>' +
  679. '<div class="slot"><p>bar</p></div>' +
  680. '<div class="slot"><p>baz</p></div>' +
  681. '</div>' +
  682. '</div>'
  683. );
  684. } );
  685. it( 'should not convert element on removing slot', () => {
  686. setModelData( model,
  687. '<complex>' +
  688. '<slot><paragraph>foo</paragraph></slot>' +
  689. '<slot><paragraph>bar</paragraph></slot>' +
  690. '</complex>' );
  691. model.change( writer => {
  692. writer.remove( modelRoot.getChild( 0 ).getChild( 0 ) );
  693. } );
  694. expectResult(
  695. '<div class="complex-slots">' +
  696. '<div class="slots">' +
  697. '<div class="slot"><p>bar</p></div>' +
  698. '</div>' +
  699. '</div>'
  700. );
  701. } );
  702. it( 'should convert element on a trigger and block atomic converters (remove + attribute)', () => {
  703. setModelData( model,
  704. '<complex>' +
  705. '<slot><paragraph>foo</paragraph></slot>' +
  706. '<slot><paragraph>bar</paragraph></slot>' +
  707. '</complex>' );
  708. model.change( writer => {
  709. writer.remove( modelRoot.getChild( 0 ).getChild( 0 ) );
  710. writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) );
  711. } );
  712. expectResult(
  713. '<div class="complex-slots with-class">' +
  714. '<div class="slots">' +
  715. '<div class="slot"><p>bar</p></div>' +
  716. '</div>' +
  717. '</div>'
  718. );
  719. } );
  720. it( 'should convert element on a trigger and block atomic converters (insert + attribute)', () => {
  721. setModelData( model,
  722. '<complex>' +
  723. '<slot><paragraph>foo</paragraph></slot>' +
  724. '<slot><paragraph>bar</paragraph></slot>' +
  725. '</complex>' );
  726. model.change( writer => {
  727. writer.insert( modelRoot.getChild( 0 ).getChild( 0 ) );
  728. writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) );
  729. } );
  730. expectResult(
  731. '<div class="complex-slots with-class">' +
  732. '<div class="slots">' +
  733. '<div class="slot"><p>foo</p></div>' +
  734. '<div class="slot"><p>bar</p></div>' +
  735. '<div class="slot"><p>baz</p></div>' +
  736. '</div>' +
  737. '</div>'
  738. );
  739. } );
  740. } );
  741. function getViewAttributes( modelElement ) {
  742. // TODO decide whether below is readable:
  743. const toStyle = modelElement.hasAttribute( 'toStyle' ) && { style: modelElement.getAttribute( 'toStyle' ) };
  744. const toClass = modelElement.hasAttribute( 'toClass' ) && { class: 'is-classy' };
  745. const attributes = {
  746. ...toStyle,
  747. ...toClass
  748. };
  749. return attributes;
  750. }
  751. } );
  752. } );
  753. describe( 'attributeToElement()', () => {
  754. beforeEach( () => {
  755. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  756. } );
  757. it( 'should be chainable', () => {
  758. expect( downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } ) ).to.equal( downcastHelpers );
  759. } );
  760. it( 'config.view is a string', () => {
  761. downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } );
  762. model.change( writer => {
  763. writer.insertText( 'foo', { bold: true }, modelRoot, 0 );
  764. } );
  765. expectResult( '<strong>foo</strong>' );
  766. } );
  767. it( 'can be overwritten using converterPriority', () => {
  768. downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } );
  769. downcastHelpers.attributeToElement( { model: 'bold', view: 'b', converterPriority: 'high' } );
  770. model.change( writer => {
  771. writer.insertText( 'foo', { bold: true }, modelRoot, 0 );
  772. } );
  773. expectResult( '<b>foo</b>' );
  774. } );
  775. it( 'config.view is a view element definition', () => {
  776. downcastHelpers.attributeToElement( {
  777. model: 'invert',
  778. view: {
  779. name: 'span',
  780. classes: [ 'font-light', 'bg-dark' ]
  781. }
  782. } );
  783. model.change( writer => {
  784. writer.insertText( 'foo', { invert: true }, modelRoot, 0 );
  785. } );
  786. expectResult( '<span class="bg-dark font-light">foo</span>' );
  787. expect( viewRoot.getChild( 0 ).priority ).to.equal( ViewAttributeElement.DEFAULT_PRIORITY );
  788. } );
  789. it( 'config.view allows specifying the element\'s priority', () => {
  790. downcastHelpers.attributeToElement( {
  791. model: 'invert',
  792. view: {
  793. name: 'span',
  794. priority: 5
  795. }
  796. } );
  797. model.change( writer => {
  798. writer.insertText( 'foo', { invert: true }, modelRoot, 0 );
  799. } );
  800. expect( viewRoot.getChild( 0 ).priority ).to.equal( 5 );
  801. } );
  802. it( 'model attribute value is enum', () => {
  803. downcastHelpers.attributeToElement( {
  804. model: {
  805. key: 'fontSize',
  806. values: [ 'big', 'small' ]
  807. },
  808. view: {
  809. big: {
  810. name: 'span',
  811. styles: {
  812. 'font-size': '1.2em'
  813. }
  814. },
  815. small: {
  816. name: 'span',
  817. styles: {
  818. 'font-size': '0.8em'
  819. },
  820. priority: 5
  821. }
  822. }
  823. } );
  824. model.change( writer => {
  825. writer.insertText( 'foo', { fontSize: 'big' }, modelRoot, 0 );
  826. } );
  827. expect( viewRoot.getChild( 0 ).priority ).to.equal( ViewAttributeElement.DEFAULT_PRIORITY );
  828. expectResult( '<span style="font-size:1.2em">foo</span>' );
  829. model.change( writer => {
  830. writer.setAttribute( 'fontSize', 'small', modelRoot.getChild( 0 ) );
  831. } );
  832. expectResult( '<span style="font-size:0.8em">foo</span>' );
  833. expect( viewRoot.getChild( 0 ).priority ).to.equal( 5 );
  834. model.change( writer => {
  835. writer.removeAttribute( 'fontSize', modelRoot.getChild( 0 ) );
  836. } );
  837. expectResult( 'foo' );
  838. } );
  839. it( 'config.view is a function', () => {
  840. downcastHelpers.attributeToElement( {
  841. model: 'bold',
  842. view: ( modelAttributeValue, { writer } ) => {
  843. return writer.createAttributeElement( 'span', { style: 'font-weight:' + modelAttributeValue } );
  844. }
  845. } );
  846. model.change( writer => {
  847. writer.insertText( 'foo', { bold: '500' }, modelRoot, 0 );
  848. } );
  849. expectResult( '<span style="font-weight:500">foo</span>' );
  850. } );
  851. it( 'config.model.name is given', () => {
  852. downcastHelpers.attributeToElement( {
  853. model: {
  854. key: 'color',
  855. name: '$text'
  856. },
  857. view: ( modelAttributeValue, { writer } ) => {
  858. return writer.createAttributeElement( 'span', { style: 'color:' + modelAttributeValue } );
  859. }
  860. } );
  861. downcastHelpers.elementToElement( {
  862. model: 'smiley',
  863. view: ( modelElement, { writer } ) => {
  864. return writer.createEmptyElement( 'img', {
  865. src: 'smile.jpg',
  866. class: 'smiley'
  867. } );
  868. }
  869. } );
  870. model.change( writer => {
  871. writer.insertText( 'foo', { color: '#FF0000' }, modelRoot, 0 );
  872. writer.insertElement( 'smiley', { color: '#FF0000' }, modelRoot, 3 );
  873. } );
  874. expectResult( '<span style="color:#FF0000">foo</span><img class="smiley" src="smile.jpg"></img>' );
  875. } );
  876. it( 'should not convert if creator returned null', () => {
  877. downcastHelpers.elementToElement( { model: 'div', view: () => null } );
  878. const modelElement = new ModelElement( 'div' );
  879. model.change( writer => {
  880. writer.insert( modelElement, modelRootStart );
  881. } );
  882. expect( viewToString( viewRoot ) ).to.equal( '<div></div>' );
  883. } );
  884. it( 'should convert insert/change/remove of attribute in model into wrapping element in a view', () => {
  885. const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) );
  886. downcastHelpers.attributeToElement( {
  887. model: 'bold',
  888. view: ( modelAttributeValue, { writer } ) => writer.createAttributeElement( 'b' )
  889. } );
  890. model.change( writer => {
  891. writer.insert( modelElement, modelRootStart );
  892. } );
  893. expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
  894. model.change( writer => {
  895. writer.removeAttribute( 'bold', writer.createRangeIn( modelElement ) );
  896. } );
  897. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
  898. } );
  899. it( 'should convert insert/remove of attribute in model with wrapping element generating function as a parameter', () => {
  900. const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { style: 'bold' } ) );
  901. downcastHelpers.attributeToElement( {
  902. model: 'style',
  903. view: ( modelAttributeValue, { writer } ) => {
  904. if ( modelAttributeValue == 'bold' ) {
  905. return writer.createAttributeElement( 'b' );
  906. }
  907. }
  908. } );
  909. model.change( writer => {
  910. writer.insert( modelElement, modelRootStart );
  911. } );
  912. expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
  913. model.change( writer => {
  914. writer.removeAttribute( 'style', writer.createRangeIn( modelElement ) );
  915. } );
  916. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
  917. } );
  918. it( 'should update range on re-wrapping attribute (#475)', () => {
  919. const modelElement = new ModelElement( 'paragraph', null, [
  920. new ModelText( 'x' ),
  921. new ModelText( 'foo', { link: 'http://foo.com' } ),
  922. new ModelText( 'x' )
  923. ] );
  924. downcastHelpers.attributeToElement( {
  925. model: 'link',
  926. view: ( modelAttributeValue, { writer } ) => {
  927. return writer.createAttributeElement( 'a', { href: modelAttributeValue } );
  928. }
  929. } );
  930. model.change( writer => {
  931. writer.insert( modelElement, modelRootStart );
  932. } );
  933. expect( viewToString( viewRoot ) ).to.equal( '<div><p>x<a href="http://foo.com">foo</a>x</p></div>' );
  934. // Set new attribute on old link but also on non-linked characters.
  935. model.change( writer => {
  936. writer.setAttribute( 'link', 'http://foobar.com', writer.createRangeIn( modelElement ) );
  937. } );
  938. expect( viewToString( viewRoot ) ).to.equal( '<div><p><a href="http://foobar.com">xfoox</a></p></div>' );
  939. } );
  940. it( 'should support unicode', () => {
  941. const modelElement = new ModelElement( 'paragraph', null, [ 'நி', new ModelText( 'லைக்', { bold: true } ), 'கு' ] );
  942. downcastHelpers.attributeToElement( {
  943. model: 'bold',
  944. view: ( modelAttributeValue, { writer } ) => writer.createAttributeElement( 'b' )
  945. } );
  946. model.change( writer => {
  947. writer.insert( modelElement, modelRootStart );
  948. } );
  949. expect( viewToString( viewRoot ) ).to.equal( '<div><p>நி<b>லைக்</b>கு</p></div>' );
  950. model.change( writer => {
  951. writer.removeAttribute( 'bold', writer.createRangeIn( modelElement ) );
  952. } );
  953. expect( viewToString( viewRoot ) ).to.equal( '<div><p>நிலைக்கு</p></div>' );
  954. } );
  955. it( 'should be possible to override ', () => {
  956. const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) );
  957. downcastHelpers.attributeToElement( {
  958. model: 'bold',
  959. view: ( modelAttributeValue, { writer } ) => writer.createAttributeElement( 'b' )
  960. } );
  961. downcastHelpers.attributeToElement( {
  962. model: 'bold',
  963. view: ( modelAttributeValue, { writer } ) => writer.createAttributeElement( 'strong' ),
  964. converterPriority: 'high'
  965. } );
  966. model.change( writer => {
  967. writer.insert( modelElement, modelRootStart );
  968. } );
  969. expect( viewToString( viewRoot ) ).to.equal( '<div><p><strong>foobar</strong></p></div>' );
  970. } );
  971. it( 'should not convert and not consume if creator function returned null', () => {
  972. sinon.spy( controller.downcastDispatcher, 'fire' );
  973. const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { italic: true } ) );
  974. downcastHelpers.attributeToElement( {
  975. model: 'italic',
  976. view: () => null
  977. } );
  978. const spy = sinon.spy();
  979. controller.downcastDispatcher.on( 'attribute:italic', spy );
  980. model.change( writer => {
  981. writer.insert( modelElement, modelRootStart );
  982. } );
  983. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
  984. expect( controller.downcastDispatcher.fire.calledWith( 'attribute:italic:$text' ) ).to.be.true;
  985. expect( spy.called ).to.be.true;
  986. } );
  987. } );
  988. describe( 'attributeToAttribute()', () => {
  989. testUtils.createSinonSandbox();
  990. beforeEach( () => {
  991. downcastHelpers.elementToElement( { model: 'image', view: 'img' } );
  992. downcastHelpers.elementToElement( {
  993. model: 'paragraph',
  994. view: ( modelItem, { writer } ) => writer.createContainerElement( 'p' )
  995. } );
  996. downcastHelpers.attributeToAttribute( {
  997. model: 'class',
  998. view: 'class'
  999. } );
  1000. } );
  1001. it( 'should be chainable', () => {
  1002. expect( downcastHelpers.attributeToAttribute( { model: 'source', view: 'src' } ) ).to.equal( downcastHelpers );
  1003. } );
  1004. it( 'config.view is a string', () => {
  1005. downcastHelpers.attributeToAttribute( { model: 'source', view: 'src' } );
  1006. model.change( writer => {
  1007. writer.insertElement( 'image', { source: 'foo.jpg' }, modelRoot, 0 );
  1008. } );
  1009. expectResult( '<img src="foo.jpg"></img>' );
  1010. model.change( writer => {
  1011. writer.removeAttribute( 'source', modelRoot.getChild( 0 ) );
  1012. } );
  1013. expectResult( '<img></img>' );
  1014. } );
  1015. it( 'can be overwritten using converterPriority', () => {
  1016. downcastHelpers.attributeToAttribute( { model: 'source', view: 'href' } );
  1017. downcastHelpers.attributeToAttribute( { model: 'source', view: 'src', converterPriority: 'high' } );
  1018. model.change( writer => {
  1019. writer.insertElement( 'image', { source: 'foo.jpg' }, modelRoot, 0 );
  1020. } );
  1021. expectResult( '<img src="foo.jpg"></img>' );
  1022. } );
  1023. it( 'model element name specified', () => {
  1024. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  1025. downcastHelpers.attributeToAttribute( {
  1026. model: {
  1027. name: 'image',
  1028. key: 'source'
  1029. },
  1030. view: 'src'
  1031. } );
  1032. model.change( writer => {
  1033. writer.insertElement( 'image', { source: 'foo.jpg' }, modelRoot, 0 );
  1034. } );
  1035. expectResult( '<img src="foo.jpg"></img>' );
  1036. model.change( writer => {
  1037. writer.rename( modelRoot.getChild( 0 ), 'paragraph' );
  1038. } );
  1039. expectResult( '<p></p>' );
  1040. } );
  1041. it( 'config.view is an object, model attribute value is enum', () => {
  1042. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  1043. downcastHelpers.attributeToAttribute( {
  1044. model: {
  1045. key: 'styled',
  1046. values: [ 'dark', 'light' ]
  1047. },
  1048. view: {
  1049. dark: {
  1050. key: 'class',
  1051. value: [ 'styled', 'styled-dark' ]
  1052. },
  1053. light: {
  1054. key: 'class',
  1055. value: [ 'styled', 'styled-light' ]
  1056. }
  1057. }
  1058. } );
  1059. model.change( writer => {
  1060. writer.insertElement( 'paragraph', { styled: 'dark' }, modelRoot, 0 );
  1061. } );
  1062. expectResult( '<p class="styled styled-dark"></p>' );
  1063. model.change( writer => {
  1064. writer.setAttribute( 'styled', 'light', modelRoot.getChild( 0 ) );
  1065. } );
  1066. expectResult( '<p class="styled styled-light"></p>' );
  1067. model.change( writer => {
  1068. writer.removeAttribute( 'styled', modelRoot.getChild( 0 ) );
  1069. } );
  1070. expectResult( '<p></p>' );
  1071. } );
  1072. it( 'config.view is an object, model attribute value is enum, view has style', () => {
  1073. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  1074. downcastHelpers.attributeToAttribute( {
  1075. model: {
  1076. key: 'align',
  1077. values: [ 'right', 'center' ]
  1078. },
  1079. view: {
  1080. right: {
  1081. key: 'style',
  1082. value: {
  1083. 'text-align': 'right'
  1084. }
  1085. },
  1086. center: {
  1087. key: 'style',
  1088. value: {
  1089. 'text-align': 'center'
  1090. }
  1091. }
  1092. }
  1093. } );
  1094. model.change( writer => {
  1095. writer.insertElement( 'paragraph', { align: 'right' }, modelRoot, 0 );
  1096. } );
  1097. expectResult( '<p style="text-align:right"></p>' );
  1098. model.change( writer => {
  1099. writer.setAttribute( 'align', 'center', modelRoot.getChild( 0 ) );
  1100. } );
  1101. expectResult( '<p style="text-align:center"></p>' );
  1102. model.change( writer => {
  1103. writer.removeAttribute( 'align', modelRoot.getChild( 0 ) );
  1104. } );
  1105. expectResult( '<p></p>' );
  1106. } );
  1107. it( 'config.view is an object, only name and key are provided', () => {
  1108. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  1109. downcastHelpers.attributeToAttribute( {
  1110. model: {
  1111. name: 'paragraph',
  1112. key: 'class'
  1113. },
  1114. view: {
  1115. name: 'paragraph',
  1116. key: 'class'
  1117. }
  1118. } );
  1119. model.change( writer => {
  1120. writer.insertElement( 'paragraph', { class: 'dark' }, modelRoot, 0 );
  1121. } );
  1122. expectResult( '<p class="dark"></p>' );
  1123. model.change( writer => {
  1124. writer.setAttribute( 'class', 'light', modelRoot.getChild( 0 ) );
  1125. } );
  1126. expectResult( '<p class="light"></p>' );
  1127. model.change( writer => {
  1128. writer.removeAttribute( 'class', modelRoot.getChild( 0 ) );
  1129. } );
  1130. expectResult( '<p></p>' );
  1131. } );
  1132. it( 'config.view is a function', () => {
  1133. downcastHelpers.attributeToAttribute( {
  1134. model: 'styled',
  1135. view: ( attributeValue, conversionApi ) => {
  1136. // To ensure conversion API is provided.
  1137. expect( conversionApi.writer ).to.instanceof( DowncastWriter );
  1138. return { key: 'class', value: 'styled-' + attributeValue };
  1139. }
  1140. } );
  1141. model.change( writer => {
  1142. writer.insertElement( 'image', { styled: 'pull-out' }, modelRoot, 0 );
  1143. } );
  1144. expectResult( '<img class="styled-pull-out"></img>' );
  1145. } );
  1146. // #1587
  1147. it( 'config.view and config.model as strings in generic conversion (elements only)', () => {
  1148. const consoleWarnStub = testUtils.sinon.stub( console, 'warn' );
  1149. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  1150. downcastHelpers.attributeToAttribute( { model: 'test', view: 'test' } );
  1151. model.change( writer => {
  1152. writer.insertElement( 'paragraph', { test: '1' }, modelRoot, 0 );
  1153. writer.insertElement( 'paragraph', { test: '2' }, modelRoot, 1 );
  1154. } );
  1155. expectResult( '<p test="1"></p><p test="2"></p>' );
  1156. expect( consoleWarnStub.callCount ).to.equal( 0 );
  1157. model.change( writer => {
  1158. writer.removeAttribute( 'test', modelRoot.getChild( 1 ) );
  1159. } );
  1160. expectResult( '<p test="1"></p><p></p>' );
  1161. } );
  1162. // #1587
  1163. it( 'config.view and config.model as strings in generic conversion (elements + text)', () => {
  1164. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  1165. downcastHelpers.attributeToAttribute( { model: 'test', view: 'test' } );
  1166. expectToThrowCKEditorError( () => {
  1167. model.change( writer => {
  1168. writer.insertElement( 'paragraph', modelRoot, 0 );
  1169. writer.insertElement( 'paragraph', { test: '1' }, modelRoot, 1 );
  1170. writer.insertText( 'Foo', { test: '2' }, modelRoot.getChild( 0 ), 0 );
  1171. writer.insertText( 'Bar', { test: '3' }, modelRoot.getChild( 1 ), 0 );
  1172. } );
  1173. }, /^conversion-attribute-to-attribute-on-text/ );
  1174. } );
  1175. it( 'should convert attribute insert/change/remove on a model node', () => {
  1176. const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) );
  1177. model.change( writer => {
  1178. writer.insert( modelElement, modelRootStart );
  1179. } );
  1180. expect( viewToString( viewRoot ) ).to.equal( '<div><p class="foo">foobar</p></div>' );
  1181. model.change( writer => {
  1182. writer.setAttribute( 'class', 'bar', modelElement );
  1183. } );
  1184. expect( viewToString( viewRoot ) ).to.equal( '<div><p class="bar">foobar</p></div>' );
  1185. model.change( writer => {
  1186. writer.removeAttribute( 'class', modelElement );
  1187. } );
  1188. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
  1189. } );
  1190. it( 'should be possible to override setAttribute', () => {
  1191. downcastHelpers.attributeToAttribute( {
  1192. model: 'class',
  1193. view: ( evt, data, conversionApi ) => {
  1194. conversionApi.consumable.consume( data.item, 'attribute:class' );
  1195. },
  1196. converterPriority: 'high'
  1197. } );
  1198. model.change( writer => {
  1199. const modelElement = new ModelElement( 'paragraph', { classes: 'foo' }, new ModelText( 'foobar' ) );
  1200. writer.insert( modelElement, modelRootStart );
  1201. } );
  1202. // No attribute set.
  1203. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
  1204. } );
  1205. it( 'should not convert or consume if element creator returned null', () => {
  1206. const callback = sinon.stub().returns( null );
  1207. downcastHelpers.attributeToAttribute( {
  1208. model: 'class',
  1209. view: callback,
  1210. converterPriority: 'high'
  1211. } );
  1212. const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) );
  1213. model.change( writer => {
  1214. writer.insert( modelElement, modelRootStart );
  1215. } );
  1216. expect( viewToString( viewRoot ) ).to.equal( '<div><p class="foo">foobar</p></div>' );
  1217. sinon.assert.called( callback );
  1218. } );
  1219. } );
  1220. describe( 'markerToElement()', () => {
  1221. let modelText, modelElement, range;
  1222. it( 'should be chainable', () => {
  1223. expect( downcastHelpers.markerToElement( { model: 'search', view: 'marker-search' } ) ).to.equal( downcastHelpers );
  1224. } );
  1225. it( 'config.view is a string', () => {
  1226. downcastHelpers.markerToElement( { model: 'search', view: 'marker-search' } );
  1227. model.change( writer => {
  1228. writer.insertText( 'foo', modelRoot, 0 );
  1229. const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) );
  1230. writer.addMarker( 'search', { range, usingOperation: false } );
  1231. } );
  1232. expectResult( 'f<marker-search></marker-search>o<marker-search></marker-search>o' );
  1233. } );
  1234. it( 'can be overwritten using converterPriority', () => {
  1235. downcastHelpers.markerToElement( { model: 'search', view: 'marker-search' } );
  1236. downcastHelpers.markerToElement( { model: 'search', view: 'search', converterPriority: 'high' } );
  1237. model.change( writer => {
  1238. writer.insertText( 'foo', modelRoot, 0 );
  1239. const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) );
  1240. writer.addMarker( 'search', { range, usingOperation: false } );
  1241. } );
  1242. expectResult( 'f<search></search>o<search></search>o' );
  1243. } );
  1244. it( 'config.view is a view element definition', () => {
  1245. downcastHelpers.markerToElement( {
  1246. model: 'search',
  1247. view: {
  1248. name: 'span',
  1249. attributes: {
  1250. 'data-marker': 'search'
  1251. }
  1252. }
  1253. } );
  1254. model.change( writer => {
  1255. writer.insertText( 'foo', modelRoot, 0 );
  1256. const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) );
  1257. writer.addMarker( 'search', { range, usingOperation: false } );
  1258. } );
  1259. expectResult( 'f<span data-marker="search"></span>o<span data-marker="search"></span>o' );
  1260. } );
  1261. it( 'config.view is a function', () => {
  1262. downcastHelpers.markerToElement( {
  1263. model: 'search',
  1264. view: ( data, { writer } ) => {
  1265. return writer.createUIElement( 'span', { 'data-marker': 'search', 'data-start': data.isOpening } );
  1266. }
  1267. } );
  1268. model.change( writer => {
  1269. writer.insertText( 'foo', modelRoot, 0 );
  1270. const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) );
  1271. writer.addMarker( 'search', { range, usingOperation: false } );
  1272. } );
  1273. expectResult( 'f<span data-marker="search" data-start="true"></span>o<span data-marker="search" data-start="false"></span>o' );
  1274. } );
  1275. describe( 'collapsed range', () => {
  1276. beforeEach( () => {
  1277. modelText = new ModelText( 'foobar' );
  1278. modelElement = new ModelElement( 'paragraph', null, modelText );
  1279. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  1280. model.change( writer => {
  1281. writer.insert( modelElement, modelRootStart );
  1282. } );
  1283. range = model.createRange( model.createPositionAt( modelElement, 3 ), model.createPositionAt( modelElement, 3 ) );
  1284. } );
  1285. it( 'should insert and remove ui element', () => {
  1286. downcastHelpers.markerToElement( {
  1287. model: 'marker',
  1288. view: ( data, { writer } ) => writer.createUIElement( 'span', { 'class': 'marker' } )
  1289. } );
  1290. model.change( writer => {
  1291. writer.addMarker( 'marker', { range, usingOperation: false } );
  1292. } );
  1293. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo<span class="marker"></span>bar</p></div>' );
  1294. model.change( writer => {
  1295. writer.removeMarker( 'marker' );
  1296. } );
  1297. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
  1298. } );
  1299. it( 'should not convert if consumable was consumed', () => {
  1300. sinon.spy( controller.downcastDispatcher, 'fire' );
  1301. downcastHelpers.markerToElement( {
  1302. model: 'marker',
  1303. view: ( data, { writer } ) => writer.createUIElement( 'span', { 'class': 'marker' } )
  1304. } );
  1305. controller.downcastDispatcher.on( 'addMarker:marker', ( evt, data, conversionApi ) => {
  1306. conversionApi.consumable.consume( data.markerRange, 'addMarker:marker' );
  1307. }, { priority: 'high' } );
  1308. model.change( writer => {
  1309. writer.addMarker( 'marker', { range, usingOperation: false } );
  1310. } );
  1311. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
  1312. expect( controller.downcastDispatcher.fire.calledWith( 'addMarker:marker' ) );
  1313. } );
  1314. it( 'should not convert if creator returned null', () => {
  1315. downcastHelpers.markerToElement( {
  1316. model: 'marker',
  1317. view: () => null
  1318. } );
  1319. model.change( writer => {
  1320. writer.addMarker( 'marker', { range, usingOperation: false } );
  1321. } );
  1322. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
  1323. model.change( writer => {
  1324. writer.removeMarker( 'marker' );
  1325. } );
  1326. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
  1327. } );
  1328. } );
  1329. describe( 'non-collapsed range', () => {
  1330. beforeEach( () => {
  1331. modelText = new ModelText( 'foobar' );
  1332. modelElement = new ModelElement( 'paragraph', null, modelText );
  1333. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  1334. model.change( writer => {
  1335. writer.insert( modelElement, modelRootStart );
  1336. } );
  1337. range = model.createRange( model.createPositionAt( modelElement, 2 ), model.createPositionAt( modelElement, 5 ) );
  1338. } );
  1339. it( 'should insert and remove ui element - element as a creator', () => {
  1340. downcastHelpers.markerToElement( {
  1341. model: 'marker',
  1342. view: ( data, { writer } ) => writer.createUIElement( 'span', { 'class': 'marker' } )
  1343. } );
  1344. model.change( writer => {
  1345. writer.addMarker( 'marker', { range, usingOperation: false } );
  1346. } );
  1347. expect( viewToString( viewRoot ) )
  1348. .to.equal( '<div><p>fo<span class="marker"></span>oba<span class="marker"></span>r</p></div>' );
  1349. model.change( writer => {
  1350. writer.removeMarker( 'marker' );
  1351. } );
  1352. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
  1353. } );
  1354. it( 'should insert and remove ui element - function as a creator', () => {
  1355. downcastHelpers.markerToElement( {
  1356. model: 'marker',
  1357. view: ( data, { writer } ) => writer.createUIElement( 'span', { 'class': data.markerName } )
  1358. } );
  1359. model.change( writer => {
  1360. writer.addMarker( 'marker', { range, usingOperation: false } );
  1361. } );
  1362. expect( viewToString( viewRoot ) )
  1363. .to.equal( '<div><p>fo<span class="marker"></span>oba<span class="marker"></span>r</p></div>' );
  1364. model.change( writer => {
  1365. writer.removeMarker( 'marker' );
  1366. } );
  1367. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
  1368. } );
  1369. it( 'should insert and remove different opening and ending element', () => {
  1370. downcastHelpers.markerToElement( {
  1371. model: 'marker',
  1372. view: ( data, { writer } ) => {
  1373. if ( data.isOpening ) {
  1374. return writer.createUIElement( 'span', { 'class': data.markerName, 'data-start': true } );
  1375. }
  1376. return writer.createUIElement( 'span', { 'class': data.markerName, 'data-end': true } );
  1377. }
  1378. } );
  1379. model.change( writer => {
  1380. writer.addMarker( 'marker', { range, usingOperation: false } );
  1381. } );
  1382. expect( viewToString( viewRoot ) ).to.equal(
  1383. '<div><p>fo<span class="marker" data-start="true"></span>oba<span class="marker" data-end="true"></span>r</p></div>'
  1384. );
  1385. model.change( writer => {
  1386. writer.removeMarker( 'marker' );
  1387. } );
  1388. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
  1389. } );
  1390. it( 'should not convert if consumable was consumed', () => {
  1391. sinon.spy( controller.downcastDispatcher, 'fire' );
  1392. downcastHelpers.markerToElement( {
  1393. model: 'marker',
  1394. view: ( data, { writer } ) => writer.createUIElement( 'span', { 'class': 'marker' } )
  1395. } );
  1396. controller.downcastDispatcher.on( 'addMarker:marker', ( evt, data, conversionApi ) => {
  1397. conversionApi.consumable.consume( data.item, 'addMarker:marker' );
  1398. }, { priority: 'high' } );
  1399. model.change( writer => {
  1400. writer.addMarker( 'marker', { range, usingOperation: false } );
  1401. } );
  1402. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
  1403. expect( controller.downcastDispatcher.fire.calledWith( 'addMarker:marker' ) );
  1404. } );
  1405. } );
  1406. } );
  1407. describe( 'markerToData()', () => {
  1408. let root;
  1409. beforeEach( () => {
  1410. root = model.document.getRoot();
  1411. model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
  1412. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  1413. } );
  1414. it( 'should be chainable', () => {
  1415. expect( downcastHelpers.markerToData( { model: 'search' } ) ).to.equal( downcastHelpers );
  1416. } );
  1417. it( 'default conversion, inside text, non-collapsed, no name', () => {
  1418. downcastHelpers.markerToData( { model: 'search' } );
  1419. setModelData( model, '<paragraph>Fo[ob]ar</paragraph>' );
  1420. model.change( writer => {
  1421. writer.addMarker( 'search', { range: model.document.selection.getFirstRange(), usingOperation: false } );
  1422. } );
  1423. expectResult( '<p>Fo<search-start></search-start>ob<search-end></search-end>ar</p>' );
  1424. model.change( writer => {
  1425. writer.removeMarker( 'search' );
  1426. } );
  1427. expectResult( '<p>Foobar</p>' );
  1428. } );
  1429. it( 'default conversion, inside text, non-collapsed, name', () => {
  1430. downcastHelpers.markerToData( { model: 'group' } );
  1431. setModelData( model, '<paragraph>Fo[ob]ar</paragraph>' );
  1432. model.change( writer => {
  1433. writer.addMarker( 'group:foo:bar:baz', { range: model.document.selection.getFirstRange(), usingOperation: false } );
  1434. } );
  1435. expectResult( '<p>Fo<group-start name="foo:bar:baz"></group-start>ob<group-end name="foo:bar:baz"></group-end>ar</p>' );
  1436. model.change( writer => {
  1437. writer.removeMarker( 'group:foo:bar:baz' );
  1438. } );
  1439. expectResult( '<p>Foobar</p>' );
  1440. } );
  1441. it( 'default conversion, inside text, collapsed, no name', () => {
  1442. downcastHelpers.markerToData( { model: 'search' } );
  1443. setModelData( model, '<paragraph>Foo[]bar</paragraph>' );
  1444. model.change( writer => {
  1445. writer.addMarker( 'search', { range: model.document.selection.getFirstRange(), usingOperation: false } );
  1446. } );
  1447. expectResult( '<p>Foo<search-start></search-start><search-end></search-end>bar</p>' );
  1448. model.change( writer => {
  1449. writer.removeMarker( 'search' );
  1450. } );
  1451. expectResult( '<p>Foobar</p>' );
  1452. } );
  1453. it( 'default conversion, inside text, collapsed, multiple markers, no name', () => {
  1454. downcastHelpers.markerToData( { model: 'group' } );
  1455. setModelData( model, '<paragraph>Foo[]bar</paragraph>' );
  1456. model.change( writer => {
  1457. writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
  1458. writer.addMarker( 'group:abc', { range: model.document.selection.getFirstRange(), usingOperation: false } );
  1459. } );
  1460. expectResult(
  1461. '<p>' +
  1462. 'Foo' +
  1463. '<group-start name="abc"></group-start><group-end name="abc"></group-end>' +
  1464. '<group-start name="foo"></group-start><group-end name="foo"></group-end>' +
  1465. 'bar' +
  1466. '</p>'
  1467. );
  1468. model.change( writer => {
  1469. writer.removeMarker( 'group:foo' );
  1470. writer.removeMarker( 'group:abc' );
  1471. } );
  1472. expectResult( '<p>Foobar</p>' );
  1473. } );
  1474. it( 'default conversion, on two elements, no name', () => {
  1475. downcastHelpers.markerToData( { model: 'search' } );
  1476. setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
  1477. model.change( writer => {
  1478. const range = writer.createRangeIn( root );
  1479. writer.addMarker( 'search', { range, usingOperation: false } );
  1480. } );
  1481. expectResult( '<p data-search-start-before="">Foo</p><p data-search-end-after="">Bar</p>' );
  1482. model.change( writer => {
  1483. writer.removeMarker( 'search' );
  1484. } );
  1485. expectResult( '<p>Foo</p><p>Bar</p>' );
  1486. } );
  1487. it( 'default conversion, on two elements, name', () => {
  1488. downcastHelpers.markerToData( { model: 'group' } );
  1489. setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
  1490. model.change( writer => {
  1491. const range = writer.createRangeIn( root );
  1492. writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
  1493. } );
  1494. expectResult( '<p data-group-start-before="foo:bar:baz">Foo</p><p data-group-end-after="foo:bar:baz">Bar</p>' );
  1495. model.change( writer => {
  1496. writer.removeMarker( 'group:foo:bar:baz' );
  1497. } );
  1498. expectResult( '<p>Foo</p><p>Bar</p>' );
  1499. } );
  1500. it( 'default conversion, on one element, name', () => {
  1501. downcastHelpers.markerToData( { model: 'group' } );
  1502. setModelData( model, '<paragraph>Foobar</paragraph>' );
  1503. model.change( writer => {
  1504. const range = writer.createRangeIn( root );
  1505. writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
  1506. } );
  1507. expectResult( '<p data-group-end-after="foo:bar:baz" data-group-start-before="foo:bar:baz">Foobar</p>' );
  1508. model.change( writer => {
  1509. writer.removeMarker( 'group:foo:bar:baz' );
  1510. } );
  1511. expectResult( '<p>Foobar</p>' );
  1512. } );
  1513. it( 'default conversion, collapsed before element, name', () => {
  1514. downcastHelpers.markerToData( { model: 'group' } );
  1515. setModelData( model, '<paragraph>Foobar</paragraph>' );
  1516. model.change( writer => {
  1517. // Collapsed before <paragraph>.
  1518. const range = writer.createRange(
  1519. writer.createPositionFromPath( root, [ 0 ] )
  1520. );
  1521. writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
  1522. } );
  1523. expectResult( '<p data-group-end-before="foo:bar:baz" data-group-start-before="foo:bar:baz">Foobar</p>' );
  1524. model.change( writer => {
  1525. writer.removeMarker( 'group:foo:bar:baz' );
  1526. } );
  1527. expectResult( '<p>Foobar</p>' );
  1528. } );
  1529. it( 'default conversion, collapsed after element, name', () => {
  1530. downcastHelpers.markerToData( { model: 'group' } );
  1531. setModelData( model, '<paragraph>Foobar</paragraph>' );
  1532. model.change( writer => {
  1533. // Collapsed before <paragraph>.
  1534. const range = writer.createRange(
  1535. writer.createPositionFromPath( root, [ 1 ] )
  1536. );
  1537. writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
  1538. } );
  1539. expectResult( '<p data-group-end-after="foo:bar:baz" data-group-start-after="foo:bar:baz">Foobar</p>' );
  1540. model.change( writer => {
  1541. writer.removeMarker( 'group:foo:bar:baz' );
  1542. } );
  1543. expectResult( '<p>Foobar</p>' );
  1544. } );
  1545. it( 'default conversion, mixed, multiple markers, name', () => {
  1546. downcastHelpers.markerToData( { model: 'group' } );
  1547. setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
  1548. model.change( writer => {
  1549. const range = writer.createRange(
  1550. writer.createPositionFromPath( root, [ 0 ] ),
  1551. writer.createPositionFromPath( root, [ 1, 2 ] )
  1552. );
  1553. writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
  1554. writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
  1555. } );
  1556. expectResult(
  1557. '<p data-group-start-before="abc:xyz,foo:bar">Foo</p>' +
  1558. '<p>Ba<group-end name="abc:xyz"></group-end><group-end name="foo:bar"></group-end>r</p>'
  1559. );
  1560. model.change( writer => {
  1561. writer.removeMarker( 'group:foo:bar' );
  1562. writer.removeMarker( 'group:abc:xyz' );
  1563. } );
  1564. expectResult( '<p>Foo</p><p>Bar</p>' );
  1565. } );
  1566. it( 'default conversion, mixed #2, multiple markers, name', () => {
  1567. downcastHelpers.markerToData( { model: 'group' } );
  1568. setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
  1569. model.change( writer => {
  1570. const range = writer.createRange(
  1571. writer.createPositionFromPath( root, [ 0, 1 ] ),
  1572. writer.createPositionFromPath( root, [ 2 ] )
  1573. );
  1574. writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
  1575. writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
  1576. } );
  1577. expectResult(
  1578. '<p>F<group-start name="abc:xyz"></group-start><group-start name="foo:bar"></group-start>oo</p>' +
  1579. '<p data-group-end-after="abc:xyz,foo:bar">Bar</p>'
  1580. );
  1581. model.change( writer => {
  1582. writer.removeMarker( 'group:foo:bar' );
  1583. writer.removeMarker( 'group:abc:xyz' );
  1584. } );
  1585. expectResult( '<p>Foo</p><p>Bar</p>' );
  1586. } );
  1587. it( 'default conversion, mixed #3, multiple markers, name', () => {
  1588. downcastHelpers.markerToData( { model: 'group' } );
  1589. setModelData( model, '<paragraph>Foo</paragraph>' );
  1590. model.change( writer => {
  1591. const range = writer.createRange(
  1592. writer.createPositionFromPath( root, [ 0 ] ),
  1593. writer.createPositionFromPath( root, [ 0, 2 ] )
  1594. );
  1595. writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
  1596. writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
  1597. } );
  1598. expectResult(
  1599. '<p data-group-start-before="abc:xyz,foo:bar">' +
  1600. 'Fo<group-end name="abc:xyz"></group-end><group-end name="foo:bar"></group-end>o' +
  1601. '</p>'
  1602. );
  1603. model.change( writer => {
  1604. writer.removeMarker( 'group:foo:bar' );
  1605. writer.removeMarker( 'group:abc:xyz' );
  1606. } );
  1607. expectResult( '<p>Foo</p>' );
  1608. } );
  1609. it( 'default conversion, mixed #4, multiple markers, name', () => {
  1610. downcastHelpers.markerToData( { model: 'group' } );
  1611. setModelData( model, '<paragraph>Foo</paragraph>' );
  1612. model.change( writer => {
  1613. const range = writer.createRange(
  1614. writer.createPositionFromPath( root, [ 0, 2 ] ),
  1615. writer.createPositionFromPath( root, [ 1 ] )
  1616. );
  1617. writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
  1618. writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
  1619. } );
  1620. expectResult(
  1621. '<p data-group-end-after="abc:xyz,foo:bar">' +
  1622. 'Fo<group-start name="abc:xyz"></group-start><group-start name="foo:bar"></group-start>o' +
  1623. '</p>'
  1624. );
  1625. model.change( writer => {
  1626. writer.removeMarker( 'group:foo:bar' );
  1627. writer.removeMarker( 'group:abc:xyz' );
  1628. } );
  1629. expectResult( '<p>Foo</p>' );
  1630. } );
  1631. it( 'conversion callback, mixed, multiple markers, name', () => {
  1632. const customData = {
  1633. foo: 'bar',
  1634. abc: 'xyz'
  1635. };
  1636. downcastHelpers.markerToData( {
  1637. model: 'group',
  1638. view: ( markerName, conversionApi ) => {
  1639. const namePart = markerName.split( ':' )[ 1 ];
  1640. // To ensure conversion API is provided.
  1641. expect( conversionApi.writer ).to.instanceof( DowncastWriter );
  1642. return {
  1643. group: 'g',
  1644. name: namePart + '_' + customData[ namePart ]
  1645. };
  1646. }
  1647. } );
  1648. setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
  1649. model.change( writer => {
  1650. const range = writer.createRange(
  1651. writer.createPositionFromPath( root, [ 0 ] ),
  1652. writer.createPositionFromPath( root, [ 1, 2 ] )
  1653. );
  1654. writer.addMarker( 'group:foo', { range, usingOperation: false } );
  1655. writer.addMarker( 'group:abc', { range, usingOperation: false } );
  1656. } );
  1657. expectResult(
  1658. '<p data-g-start-before="abc_xyz,foo_bar">Foo</p>' +
  1659. '<p>Ba<g-end name="abc_xyz"></g-end><g-end name="foo_bar"></g-end>r</p>'
  1660. );
  1661. model.change( writer => {
  1662. writer.removeMarker( 'group:foo' );
  1663. writer.removeMarker( 'group:abc' );
  1664. } );
  1665. expectResult( '<p>Foo</p><p>Bar</p>' );
  1666. } );
  1667. it( 'conversion callback, mixed #2, multiple markers, name', () => {
  1668. const customData = {
  1669. foo: 'bar',
  1670. abc: 'xyz'
  1671. };
  1672. downcastHelpers.markerToData( {
  1673. model: 'group',
  1674. view: markerName => {
  1675. const namePart = markerName.split( ':' )[ 1 ];
  1676. return {
  1677. group: 'g',
  1678. name: namePart + '_' + customData[ namePart ]
  1679. };
  1680. }
  1681. } );
  1682. setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
  1683. model.change( writer => {
  1684. const range = writer.createRange(
  1685. writer.createPositionFromPath( root, [ 0, 1 ] ),
  1686. writer.createPositionFromPath( root, [ 2 ] )
  1687. );
  1688. writer.addMarker( 'group:foo', { range, usingOperation: false } );
  1689. writer.addMarker( 'group:abc', { range, usingOperation: false } );
  1690. } );
  1691. expectResult(
  1692. '<p>F<g-start name="abc_xyz"></g-start><g-start name="foo_bar"></g-start>oo</p>' +
  1693. '<p data-g-end-after="abc_xyz,foo_bar">Bar</p>'
  1694. );
  1695. model.change( writer => {
  1696. writer.removeMarker( 'group:foo' );
  1697. writer.removeMarker( 'group:abc' );
  1698. } );
  1699. expectResult( '<p>Foo</p><p>Bar</p>' );
  1700. } );
  1701. it( 'can be overwritten using converterPriority', () => {
  1702. downcastHelpers.markerToData( {
  1703. model: 'group'
  1704. } );
  1705. downcastHelpers.markerToData( {
  1706. model: 'group',
  1707. view: markerName => {
  1708. const name = markerName.split( ':' )[ 1 ];
  1709. return {
  1710. group: 'g',
  1711. name
  1712. };
  1713. },
  1714. converterPriority: 'high'
  1715. } );
  1716. setModelData( model, '<paragraph>F[ooba]r</paragraph>' );
  1717. model.change( writer => {
  1718. writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
  1719. } );
  1720. expectResult(
  1721. '<p>F<g-start name="foo"></g-start>ooba<g-end name="foo"></g-end>r</p>'
  1722. );
  1723. model.change( writer => {
  1724. writer.removeMarker( 'group:foo' );
  1725. } );
  1726. expectResult( '<p>Foobar</p>' );
  1727. } );
  1728. it( 'can be overwritten by custom callback', () => {
  1729. downcastHelpers.markerToData( {
  1730. model: 'group'
  1731. } );
  1732. downcastHelpers.add( dispatcher => {
  1733. dispatcher.on( 'addMarker:group', ( evt, data, conversionApi ) => {
  1734. conversionApi.consumable.consume( data.markerRange, evt.name );
  1735. }, { priority: 'high' } );
  1736. } );
  1737. setModelData( model, '<paragraph>Foo[]bar</paragraph>' );
  1738. model.change( writer => {
  1739. writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
  1740. } );
  1741. expectResult( '<p>Foobar</p>' );
  1742. model.change( writer => {
  1743. writer.removeMarker( 'group:foo' );
  1744. } );
  1745. expectResult( '<p>Foobar</p>' );
  1746. } );
  1747. it( 'should not perform conversion if the callback returned falsy value', () => {
  1748. downcastHelpers.markerToData( {
  1749. model: 'group',
  1750. view: () => false
  1751. } );
  1752. setModelData( model, '<paragraph>F[ooba]r</paragraph>' );
  1753. model.change( writer => {
  1754. writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
  1755. } );
  1756. expectResult( '<p>Foobar</p>' );
  1757. model.change( writer => {
  1758. writer.removeMarker( 'group:foo' );
  1759. } );
  1760. expectResult( '<p>Foobar</p>' );
  1761. } );
  1762. } );
  1763. describe( 'markerToHighlight()', () => {
  1764. it( 'should be chainable', () => {
  1765. expect( downcastHelpers.markerToHighlight( { model: 'comment', view: { classes: 'comment' } } ) ).to.equal( downcastHelpers );
  1766. } );
  1767. it( 'config.view is a highlight descriptor', () => {
  1768. downcastHelpers.markerToHighlight( { model: 'comment', view: { classes: 'comment' } } );
  1769. model.change( writer => {
  1770. writer.insertText( 'foo', modelRoot, 0 );
  1771. const range = writer.createRange( writer.createPositionAt( modelRoot, 0 ), writer.createPositionAt( modelRoot, 3 ) );
  1772. writer.addMarker( 'comment', { range, usingOperation: false } );
  1773. } );
  1774. expectResult( '<span class="comment">foo</span>' );
  1775. } );
  1776. it( 'can be overwritten using converterPriority', () => {
  1777. downcastHelpers.markerToHighlight( { model: 'comment', view: { classes: 'comment' } } );
  1778. downcastHelpers.markerToHighlight( { model: 'comment', view: { classes: 'new-comment' }, converterPriority: 'high' } );
  1779. model.change( writer => {
  1780. writer.insertText( 'foo', modelRoot, 0 );
  1781. const range = writer.createRange( writer.createPositionAt( modelRoot, 0 ), writer.createPositionAt( modelRoot, 3 ) );
  1782. writer.addMarker( 'comment', { range, usingOperation: false } );
  1783. } );
  1784. expectResult( '<span class="new-comment">foo</span>' );
  1785. } );
  1786. it( 'config.view is a function', () => {
  1787. downcastHelpers.markerToHighlight( {
  1788. model: 'comment',
  1789. view: ( data, conversionApi ) => {
  1790. const commentType = data.markerName.split( ':' )[ 1 ];
  1791. // To ensure conversion API is provided.
  1792. expect( conversionApi.writer ).to.instanceof( DowncastWriter );
  1793. return {
  1794. classes: [ 'comment', 'comment-' + commentType ]
  1795. };
  1796. }
  1797. } );
  1798. model.change( writer => {
  1799. writer.insertText( 'foo', modelRoot, 0 );
  1800. const range = writer.createRange( writer.createPositionAt( modelRoot, 0 ), writer.createPositionAt( modelRoot, 3 ) );
  1801. writer.addMarker( 'comment:abc', { range, usingOperation: false } );
  1802. } );
  1803. expectResult( '<span class="comment comment-abc">foo</span>' );
  1804. } );
  1805. describe( 'highlight', () => {
  1806. const highlightConfig = {
  1807. model: 'marker',
  1808. view: {
  1809. classes: 'highlight-class',
  1810. attributes: { title: 'title' }
  1811. },
  1812. converterPriority: 7
  1813. };
  1814. describe( 'on text', () => {
  1815. let markerRange;
  1816. beforeEach( () => {
  1817. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  1818. const modelElement1 = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
  1819. const modelElement2 = new ModelElement( 'paragraph', null, new ModelText( 'bar' ) );
  1820. model.change( writer => {
  1821. writer.insert( [ modelElement1, modelElement2 ], modelRootStart );
  1822. } );
  1823. markerRange = model.createRangeIn( modelRoot );
  1824. } );
  1825. it( 'should wrap and unwrap text nodes', () => {
  1826. downcastHelpers.markerToHighlight( highlightConfig );
  1827. model.change( writer => {
  1828. writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
  1829. } );
  1830. expect( viewToString( viewRoot ) ).to.equal(
  1831. '<div>' +
  1832. '<p>' +
  1833. '<span class="highlight-class" title="title">foo</span>' +
  1834. '</p>' +
  1835. '<p>' +
  1836. '<span class="highlight-class" title="title">bar</span>' +
  1837. '</p>' +
  1838. '</div>'
  1839. );
  1840. model.change( writer => {
  1841. writer.removeMarker( 'marker' );
  1842. } );
  1843. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
  1844. } );
  1845. it( 'should be possible to overwrite', () => {
  1846. downcastHelpers.markerToHighlight( highlightConfig );
  1847. downcastHelpers.markerToHighlight( {
  1848. model: 'marker',
  1849. view: { classes: 'override-class' },
  1850. converterPriority: 'high'
  1851. } );
  1852. model.change( writer => {
  1853. writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
  1854. } );
  1855. expect( viewToString( viewRoot ) ).to.equal(
  1856. '<div>' +
  1857. '<p>' +
  1858. '<span class="override-class">foo</span>' +
  1859. '</p>' +
  1860. '<p>' +
  1861. '<span class="override-class">bar</span>' +
  1862. '</p>' +
  1863. '</div>'
  1864. );
  1865. model.change( writer => {
  1866. writer.removeMarker( 'marker' );
  1867. } );
  1868. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
  1869. } );
  1870. it( 'should do nothing if descriptor is not provided or generating function returns null', () => {
  1871. downcastHelpers.markerToHighlight( {
  1872. model: 'marker',
  1873. view: () => null,
  1874. converterPriority: 'high'
  1875. } );
  1876. model.change( writer => {
  1877. writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
  1878. } );
  1879. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
  1880. model.change( writer => {
  1881. writer.removeMarker( 'marker' );
  1882. } );
  1883. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
  1884. } );
  1885. it( 'should do nothing if collapsed marker is converted', () => {
  1886. downcastHelpers.markerToHighlight( {
  1887. model: 'marker',
  1888. view: { classes: 'foo' },
  1889. converterPriority: 'high'
  1890. } );
  1891. markerRange = model.createRange( model.createPositionAt( modelRoot, 0 ), model.createPositionAt( modelRoot, 0 ) );
  1892. model.change( writer => {
  1893. writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
  1894. } );
  1895. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
  1896. model.change( () => {
  1897. model.markers._remove( 'marker' );
  1898. } );
  1899. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
  1900. } );
  1901. it( 'should correctly wrap and unwrap multiple, intersecting markers', () => {
  1902. downcastHelpers.markerToHighlight( {
  1903. model: 'markerFoo',
  1904. view: { classes: 'foo' }
  1905. } );
  1906. downcastHelpers.markerToHighlight( {
  1907. model: 'markerBar',
  1908. view: { classes: 'bar' }
  1909. } );
  1910. downcastHelpers.markerToHighlight( {
  1911. model: 'markerXyz',
  1912. view: { classes: 'xyz' }
  1913. } );
  1914. const p1 = modelRoot.getChild( 0 );
  1915. const p2 = modelRoot.getChild( 1 );
  1916. model.change( writer => {
  1917. const range = writer.createRange( writer.createPositionAt( p1, 0 ), writer.createPositionAt( p1, 3 ) );
  1918. writer.addMarker( 'markerFoo', { range, usingOperation: false } );
  1919. } );
  1920. expect( viewToString( viewRoot ) ).to.equal(
  1921. '<div>' +
  1922. '<p>' +
  1923. '<span class="foo">foo</span>' +
  1924. '</p>' +
  1925. '<p>bar</p>' +
  1926. '</div>'
  1927. );
  1928. model.change( writer => {
  1929. const range = writer.createRange( writer.createPositionAt( p1, 1 ), writer.createPositionAt( p2, 2 ) );
  1930. writer.addMarker( 'markerBar', { range, usingOperation: false } );
  1931. } );
  1932. expect( viewToString( viewRoot ) ).to.equal(
  1933. '<div>' +
  1934. '<p>' +
  1935. '<span class="foo">f</span>' +
  1936. '<span class="bar">' +
  1937. '<span class="foo">oo</span>' +
  1938. '</span>' +
  1939. '</p>' +
  1940. '<p>' +
  1941. '<span class="bar">ba</span>' +
  1942. 'r' +
  1943. '</p>' +
  1944. '</div>'
  1945. );
  1946. model.change( writer => {
  1947. const range = writer.createRange( writer.createPositionAt( p1, 2 ), writer.createPositionAt( p2, 3 ) );
  1948. writer.addMarker( 'markerXyz', { range, usingOperation: false } );
  1949. } );
  1950. expect( viewToString( viewRoot ) ).to.equal(
  1951. '<div>' +
  1952. '<p>' +
  1953. '<span class="foo">f</span>' +
  1954. '<span class="bar">' +
  1955. '<span class="foo">' +
  1956. 'o' +
  1957. '<span class="xyz">o</span>' +
  1958. '</span>' +
  1959. '</span>' +
  1960. '</p>' +
  1961. '<p>' +
  1962. '<span class="bar">' +
  1963. '<span class="xyz">ba</span>' +
  1964. '</span>' +
  1965. '<span class="xyz">r</span>' +
  1966. '</p>' +
  1967. '</div>'
  1968. );
  1969. model.change( writer => {
  1970. writer.removeMarker( 'markerBar' );
  1971. } );
  1972. expect( viewToString( viewRoot ) ).to.equal(
  1973. '<div>' +
  1974. '<p>' +
  1975. '<span class="foo">' +
  1976. 'fo' +
  1977. '<span class="xyz">o</span>' +
  1978. '</span>' +
  1979. '</p>' +
  1980. '<p>' +
  1981. '<span class="xyz">bar</span>' +
  1982. '</p>' +
  1983. '</div>'
  1984. );
  1985. model.change( writer => {
  1986. writer.removeMarker( 'markerFoo' );
  1987. } );
  1988. expect( viewToString( viewRoot ) ).to.equal(
  1989. '<div>' +
  1990. '<p>' +
  1991. 'fo' +
  1992. '<span class="xyz">o</span>' +
  1993. '</p>' +
  1994. '<p>' +
  1995. '<span class="xyz">bar</span>' +
  1996. '</p>' +
  1997. '</div>'
  1998. );
  1999. model.change( writer => {
  2000. writer.removeMarker( 'markerXyz' );
  2001. } );
  2002. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
  2003. } );
  2004. it( 'should do nothing if marker is applied and removed on empty-ish range', () => {
  2005. downcastHelpers.markerToHighlight( highlightConfig );
  2006. const p1 = modelRoot.getChild( 0 );
  2007. const p2 = modelRoot.getChild( 1 );
  2008. const markerRange = model.createRange( model.createPositionAt( p1, 3 ), model.createPositionAt( p2, 0 ) );
  2009. model.change( writer => {
  2010. writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
  2011. } );
  2012. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
  2013. model.change( writer => {
  2014. writer.removeMarker( 'marker', { range: markerRange, usingOperation: false } );
  2015. } );
  2016. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
  2017. } );
  2018. } );
  2019. describe( 'on element', () => {
  2020. const highlightConfig = {
  2021. model: 'marker',
  2022. view: {
  2023. classes: 'highlight-class',
  2024. attributes: { title: 'title' },
  2025. id: 'customId'
  2026. },
  2027. converterPriority: 7
  2028. };
  2029. let markerRange, viewDocument;
  2030. beforeEach( () => {
  2031. viewDocument = new ViewDocument( new StylesProcessor() );
  2032. downcastHelpers.elementToElement( {
  2033. model: 'div',
  2034. view: () => {
  2035. const viewContainer = new ViewContainerElement( viewDocument, 'div' );
  2036. viewContainer._setCustomProperty( 'addHighlight', ( element, descriptor, writer ) => {
  2037. writer.addClass( descriptor.classes, element );
  2038. } );
  2039. viewContainer._setCustomProperty( 'removeHighlight', ( element, id, writer ) => {
  2040. writer.setAttribute( 'class', '', element );
  2041. } );
  2042. return viewContainer;
  2043. }
  2044. } );
  2045. const modelElement = new ModelElement( 'div', null, new ModelText( 'foo' ) );
  2046. model.change( writer => {
  2047. writer.insert( modelElement, modelRootStart );
  2048. } );
  2049. markerRange = model.createRangeOn( modelElement );
  2050. downcastHelpers.markerToHighlight( highlightConfig );
  2051. } );
  2052. it( 'should use addHighlight and removeHighlight on elements and not convert children nodes', () => {
  2053. model.change( writer => {
  2054. writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
  2055. } );
  2056. expect( viewToString( viewRoot ) ).to.equal(
  2057. '<div>' +
  2058. '<div class="highlight-class">' +
  2059. 'foo' +
  2060. '</div>' +
  2061. '</div>'
  2062. );
  2063. model.change( writer => {
  2064. writer.removeMarker( 'marker' );
  2065. } );
  2066. expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
  2067. } );
  2068. it( 'should be possible to override', () => {
  2069. downcastHelpers.markerToHighlight( {
  2070. model: 'marker',
  2071. view: { classes: 'override-class' },
  2072. converterPriority: 'high'
  2073. } );
  2074. model.change( writer => {
  2075. writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
  2076. } );
  2077. expect( viewToString( viewRoot ) ).to.equal(
  2078. '<div>' +
  2079. '<div class="override-class">' +
  2080. 'foo' +
  2081. '</div>' +
  2082. '</div>'
  2083. );
  2084. model.change( writer => {
  2085. writer.removeMarker( 'marker' );
  2086. } );
  2087. expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
  2088. } );
  2089. it( 'should use default priority and id if not provided', () => {
  2090. const viewDiv = viewRoot.getChild( 0 );
  2091. downcastHelpers.markerToHighlight( {
  2092. model: 'marker2',
  2093. view: () => null,
  2094. converterPriority: 'high'
  2095. } );
  2096. viewDiv._setCustomProperty( 'addHighlight', ( element, descriptor ) => {
  2097. expect( descriptor.priority ).to.equal( ViewAttributeElement.DEFAULT_PRIORITY );
  2098. expect( descriptor.id ).to.equal( 'marker:foo-bar-baz' );
  2099. } );
  2100. viewDiv._setCustomProperty( 'removeHighlight', ( element, id ) => {
  2101. expect( id ).to.equal( 'marker:foo-bar-baz' );
  2102. } );
  2103. model.change( writer => {
  2104. writer.addMarker( 'marker2', { range: markerRange, usingOperation: false } );
  2105. } );
  2106. } );
  2107. it( 'should do nothing if descriptor is not provided', () => {
  2108. downcastHelpers.markerToHighlight( {
  2109. model: 'marker2',
  2110. view: () => null
  2111. } );
  2112. model.change( writer => {
  2113. writer.addMarker( 'marker2', { range: markerRange, usingOperation: false } );
  2114. } );
  2115. expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
  2116. model.change( writer => {
  2117. writer.removeMarker( 'marker2' );
  2118. } );
  2119. expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
  2120. } );
  2121. } );
  2122. } );
  2123. } );
  2124. function expectResult( string ) {
  2125. expect( stringifyView( viewRoot, null, { ignoreRoot: true } ) ).to.equal( string );
  2126. }
  2127. } );
  2128. describe( 'downcast converters', () => {
  2129. let dispatcher, modelDoc, modelRoot, viewRoot, controller, modelRootStart, model;
  2130. beforeEach( () => {
  2131. model = new Model();
  2132. modelDoc = model.document;
  2133. modelRoot = modelDoc.createRoot();
  2134. controller = new EditingController( model, new StylesProcessor() );
  2135. viewRoot = controller.view.document.getRoot();
  2136. // Set name of view root the same as dom root.
  2137. // This is a mock of attaching view root to dom root.
  2138. controller.view.document.getRoot()._name = 'div';
  2139. dispatcher = controller.downcastDispatcher;
  2140. const downcastHelpers = new DowncastHelpers( [ dispatcher ] );
  2141. downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
  2142. modelRootStart = model.createPositionAt( modelRoot, 0 );
  2143. } );
  2144. describe( 'insertText()', () => {
  2145. it( 'should downcast text', () => {
  2146. model.change( writer => {
  2147. writer.insert( new ModelText( 'foobar' ), modelRootStart );
  2148. } );
  2149. expect( viewToString( viewRoot ) ).to.equal( '<div>foobar</div>' );
  2150. } );
  2151. it( 'should support unicode', () => {
  2152. model.change( writer => {
  2153. writer.insert( new ModelText( 'நிலைக்கு' ), modelRootStart );
  2154. } );
  2155. expect( viewToString( viewRoot ) ).to.equal( '<div>நிலைக்கு</div>' );
  2156. } );
  2157. it( 'should be possible to override it', () => {
  2158. dispatcher.on( 'insert:$text', ( evt, data, conversionApi ) => {
  2159. conversionApi.consumable.consume( data.item, 'insert' );
  2160. }, { converterPriority: 'high' } );
  2161. model.change( writer => {
  2162. writer.insert( new ModelText( 'foobar' ), modelRootStart );
  2163. } );
  2164. expect( viewToString( viewRoot ) ).to.equal( '<div></div>' );
  2165. } );
  2166. } );
  2167. // Remove converter is by default already added in `EditingController` instance.
  2168. describe( 'remove()', () => {
  2169. let viewDocument;
  2170. beforeEach( () => {
  2171. viewDocument = new ViewDocument( new StylesProcessor() );
  2172. } );
  2173. it( 'should remove items from view accordingly to changes in model #1', () => {
  2174. const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) );
  2175. model.change( writer => {
  2176. writer.insert( modelElement, modelRootStart );
  2177. } );
  2178. model.change( writer => {
  2179. writer.remove(
  2180. writer.createRange( writer.createPositionAt( modelElement, 2 ), writer.createPositionAt( modelElement, 4 ) )
  2181. );
  2182. } );
  2183. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foar</p></div>' );
  2184. } );
  2185. it( 'should be possible to overwrite', () => {
  2186. dispatcher.on( 'remove', evt => evt.stop(), { priority: 'high' } );
  2187. const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) );
  2188. model.change( writer => {
  2189. writer.insert( modelElement, modelRootStart );
  2190. } );
  2191. model.change( writer => {
  2192. writer.remove(
  2193. writer.createRange( writer.createPositionAt( modelElement, 2 ), writer.createPositionAt( modelElement, 4 ) )
  2194. );
  2195. } );
  2196. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
  2197. } );
  2198. it( 'should support unicode', () => {
  2199. const modelElement = new ModelElement( 'paragraph', null, 'நிலைக்கு' );
  2200. model.change( writer => {
  2201. writer.insert( modelElement, modelRootStart );
  2202. } );
  2203. model.change( writer => {
  2204. writer.remove(
  2205. writer.createRange( writer.createPositionAt( modelElement, 0 ), writer.createPositionAt( modelElement, 6 ) )
  2206. );
  2207. } );
  2208. expect( viewToString( viewRoot ) ).to.equal( '<div><p>கு</p></div>' );
  2209. } );
  2210. it( 'should not remove view ui elements that are placed next to removed content', () => {
  2211. modelRoot._appendChild( new ModelText( 'fozbar' ) );
  2212. viewRoot._appendChild( [
  2213. new ViewText( viewDocument, 'foz' ),
  2214. new ViewUIElement( viewDocument, 'span' ),
  2215. new ViewText( viewDocument, 'bar' )
  2216. ] );
  2217. // Remove 'b'.
  2218. model.change( writer => {
  2219. writer.remove(
  2220. writer.createRange( writer.createPositionAt( modelRoot, 3 ), writer.createPositionAt( modelRoot, 4 ) )
  2221. );
  2222. } );
  2223. expect( viewToString( viewRoot ) ).to.equal( '<div>foz<span></span>ar</div>' );
  2224. // Remove 'z'.
  2225. model.change( writer => {
  2226. writer.remove(
  2227. writer.createRange( writer.createPositionAt( modelRoot, 2 ), writer.createPositionAt( modelRoot, 3 ) )
  2228. );
  2229. } );
  2230. expect( viewToString( viewRoot ) ).to.equal( '<div>fo<span></span>ar</div>' );
  2231. } );
  2232. it( 'should remove correct amount of text when it is split by view ui element', () => {
  2233. modelRoot._appendChild( new ModelText( 'fozbar' ) );
  2234. viewRoot._appendChild( [
  2235. new ViewText( viewDocument, 'foz' ),
  2236. new ViewUIElement( viewDocument, 'span' ),
  2237. new ViewText( viewDocument, 'bar' )
  2238. ] );
  2239. // Remove 'z<span></span>b'.
  2240. model.change( writer => {
  2241. writer.remove(
  2242. writer.createRange( writer.createPositionAt( modelRoot, 2 ), writer.createPositionAt( modelRoot, 4 ) )
  2243. );
  2244. } );
  2245. expect( viewToString( viewRoot ) ).to.equal( '<div>foar</div>' );
  2246. } );
  2247. it( 'should unbind elements', () => {
  2248. const modelElement = new ModelElement( 'paragraph' );
  2249. model.change( writer => {
  2250. writer.insert( modelElement, modelRootStart );
  2251. } );
  2252. const viewElement = controller.mapper.toViewElement( modelElement );
  2253. expect( viewElement ).not.to.be.undefined;
  2254. expect( controller.mapper.toModelElement( viewElement ) ).to.equal( modelElement );
  2255. model.change( writer => {
  2256. writer.remove( modelElement );
  2257. } );
  2258. expect( controller.mapper.toViewElement( modelElement ) ).to.be.undefined;
  2259. expect( controller.mapper.toModelElement( viewElement ) ).to.be.undefined;
  2260. } );
  2261. it( 'should not break when remove() is used as part of unwrapping', () => {
  2262. const modelP = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
  2263. const modelWidget = new ModelElement( 'widget', null, modelP );
  2264. const downcastHelpers = new DowncastHelpers( [ dispatcher ] );
  2265. downcastHelpers.elementToElement( { model: 'widget', view: 'widget' } );
  2266. model.change( writer => {
  2267. writer.insert( modelWidget, modelRootStart );
  2268. } );
  2269. expect( viewToString( viewRoot ) ).to.equal( '<div><widget><p>foo</p></widget></div>' );
  2270. const viewP = controller.mapper.toViewElement( modelP );
  2271. expect( viewP ).not.to.be.undefined;
  2272. model.change( writer => {
  2273. writer.unwrap( modelWidget );
  2274. } );
  2275. expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p></div>' );
  2276. // `modelP` is now bound with newly created view element.
  2277. expect( controller.mapper.toViewElement( modelP ) ).not.to.equal( viewP );
  2278. // `viewP` is no longer bound with model element.
  2279. expect( controller.mapper.toModelElement( viewP ) ).to.be.undefined;
  2280. // View element from view root is bound to `modelP`.
  2281. expect( controller.mapper.toModelElement( viewRoot.getChild( 0 ) ) ).to.equal( modelP );
  2282. } );
  2283. it( 'should work correctly if container element after ui element is removed', () => {
  2284. // Prepare a model and view structure.
  2285. // This is done outside of conversion to put view ui elements inside easily.
  2286. const modelP1 = new ModelElement( 'paragraph' );
  2287. const modelP2 = new ModelElement( 'paragraph' );
  2288. const viewP1 = new ViewContainerElement( viewDocument, 'p' );
  2289. const viewUi1 = new ViewUIElement( viewDocument, 'span' );
  2290. const viewUi2 = new ViewUIElement( viewDocument, 'span' );
  2291. const viewP2 = new ViewContainerElement( viewDocument, 'p' );
  2292. modelRoot._appendChild( [ modelP1, modelP2 ] );
  2293. viewRoot._appendChild( [ viewP1, viewUi1, viewUi2, viewP2 ] );
  2294. controller.mapper.bindElements( modelP1, viewP1 );
  2295. controller.mapper.bindElements( modelP2, viewP2 );
  2296. // Remove second paragraph element.
  2297. model.change( writer => {
  2298. writer.remove( writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) ) );
  2299. } );
  2300. expect( viewToString( viewRoot ) ).to.equal( '<div><p></p><span></span><span></span></div>' );
  2301. } );
  2302. it( 'should work correctly if container element after text node is removed', () => {
  2303. const modelText = new ModelText( 'foo' );
  2304. const modelP = new ModelElement( 'paragraph' );
  2305. model.change( writer => {
  2306. writer.insert( [ modelText, modelP ], modelRootStart );
  2307. } );
  2308. model.change( writer => {
  2309. writer.remove( modelP );
  2310. } );
  2311. expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
  2312. } );
  2313. } );
  2314. describe( 'createViewElementFromHighlightDescriptor()', () => {
  2315. let viewWriter;
  2316. beforeEach( () => {
  2317. viewWriter = new DowncastWriter( controller.view.document );
  2318. } );
  2319. it( 'should return attribute element from descriptor object', () => {
  2320. const descriptor = {
  2321. classes: 'foo-class',
  2322. attributes: { one: '1', two: '2' },
  2323. priority: 7
  2324. };
  2325. const element = createViewElementFromHighlightDescriptor( viewWriter, descriptor );
  2326. expect( element.is( 'attributeElement' ) ).to.be.true;
  2327. expect( element.name ).to.equal( 'span' );
  2328. expect( element.priority ).to.equal( 7 );
  2329. expect( element.hasClass( 'foo-class' ) ).to.be.true;
  2330. for ( const key of Object.keys( descriptor.attributes ) ) {
  2331. expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
  2332. }
  2333. } );
  2334. it( 'should return attribute element from descriptor object - array with classes', () => {
  2335. const descriptor = {
  2336. classes: [ 'foo-class', 'bar-class' ],
  2337. attributes: { one: '1', two: '2' },
  2338. priority: 7
  2339. };
  2340. const element = createViewElementFromHighlightDescriptor( viewWriter, descriptor );
  2341. expect( element.is( 'attributeElement' ) ).to.be.true;
  2342. expect( element.name ).to.equal( 'span' );
  2343. expect( element.priority ).to.equal( 7 );
  2344. expect( element.hasClass( 'foo-class' ) ).to.be.true;
  2345. expect( element.hasClass( 'bar-class' ) ).to.be.true;
  2346. for ( const key of Object.keys( descriptor.attributes ) ) {
  2347. expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
  2348. }
  2349. } );
  2350. it( 'should create element without class', () => {
  2351. const descriptor = {
  2352. attributes: { one: '1', two: '2' },
  2353. priority: 7
  2354. };
  2355. const element = createViewElementFromHighlightDescriptor( viewWriter, descriptor );
  2356. expect( element.is( 'attributeElement' ) ).to.be.true;
  2357. expect( element.name ).to.equal( 'span' );
  2358. expect( element.priority ).to.equal( 7 );
  2359. for ( const key of Object.keys( descriptor.attributes ) ) {
  2360. expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
  2361. }
  2362. } );
  2363. it( 'should create element without priority', () => {
  2364. const descriptor = {
  2365. classes: 'foo-class',
  2366. attributes: { one: '1', two: '2' }
  2367. };
  2368. const element = createViewElementFromHighlightDescriptor( viewWriter, descriptor );
  2369. expect( element.is( 'attributeElement' ) ).to.be.true;
  2370. expect( element.name ).to.equal( 'span' );
  2371. expect( element.priority ).to.equal( ViewAttributeElement.DEFAULT_PRIORITY );
  2372. expect( element.hasClass( 'foo-class' ) ).to.be.true;
  2373. for ( const key of Object.keys( descriptor.attributes ) ) {
  2374. expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
  2375. }
  2376. } );
  2377. it( 'should create element without attributes', () => {
  2378. const descriptor = {
  2379. classes: 'foo-class',
  2380. priority: 7
  2381. };
  2382. const element = createViewElementFromHighlightDescriptor( viewWriter, descriptor );
  2383. expect( element.is( 'attributeElement' ) ).to.be.true;
  2384. expect( element.name ).to.equal( 'span' );
  2385. expect( element.priority ).to.equal( 7 );
  2386. expect( element.hasClass( 'foo-class' ) ).to.be.true;
  2387. } );
  2388. } );
  2389. } );
  2390. describe( 'downcast selection converters', () => {
  2391. let dispatcher, mapper, model, view, modelDoc, modelRoot, docSelection, viewDoc, viewRoot, viewSelection, downcastHelpers;
  2392. beforeEach( () => {
  2393. model = new Model();
  2394. modelDoc = model.document;
  2395. modelRoot = modelDoc.createRoot();
  2396. docSelection = modelDoc.selection;
  2397. model.schema.extend( '$text', { allowIn: '$root' } );
  2398. view = new View( new StylesProcessor() );
  2399. viewDoc = view.document;
  2400. viewRoot = createViewRoot( viewDoc );
  2401. viewSelection = viewDoc.selection;
  2402. mapper = new Mapper();
  2403. mapper.bindElements( modelRoot, viewRoot );
  2404. dispatcher = new DowncastDispatcher( { mapper, viewSelection } );
  2405. dispatcher.on( 'insert:$text', insertText() );
  2406. downcastHelpers = new DowncastHelpers( [ dispatcher ] );
  2407. downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } );
  2408. downcastHelpers.markerToHighlight( { model: 'marker', view: { classes: 'marker' }, converterPriority: 1 } );
  2409. // Default selection converters.
  2410. dispatcher.on( 'selection', clearAttributes(), { priority: 'low' } );
  2411. dispatcher.on( 'selection', convertRangeSelection(), { priority: 'low' } );
  2412. dispatcher.on( 'selection', convertCollapsedSelection(), { priority: 'low' } );
  2413. } );
  2414. afterEach( () => {
  2415. view.destroy();
  2416. } );
  2417. describe( 'default converters', () => {
  2418. describe( 'range selection', () => {
  2419. it( 'in same container', () => {
  2420. testSelection(
  2421. [ 1, 4 ],
  2422. 'foobar',
  2423. 'f{oob}ar'
  2424. );
  2425. } );
  2426. it( 'in same container with unicode characters', () => {
  2427. testSelection(
  2428. [ 2, 6 ],
  2429. 'நிலைக்கு',
  2430. 'நி{லைக்}கு'
  2431. );
  2432. } );
  2433. it( 'in same container, over attribute', () => {
  2434. testSelection(
  2435. [ 1, 5 ],
  2436. 'fo<$text bold="true">ob</$text>ar',
  2437. 'f{o<strong>ob</strong>a}r'
  2438. );
  2439. } );
  2440. it( 'in same container, next to attribute', () => {
  2441. testSelection(
  2442. [ 1, 2 ],
  2443. 'fo<$text bold="true">ob</$text>ar',
  2444. 'f{o}<strong>ob</strong>ar'
  2445. );
  2446. } );
  2447. it( 'in same attribute', () => {
  2448. testSelection(
  2449. [ 2, 4 ],
  2450. 'f<$text bold="true">ooba</$text>r',
  2451. 'f<strong>o{ob}a</strong>r'
  2452. );
  2453. } );
  2454. it( 'in same attribute, selection same as attribute', () => {
  2455. testSelection(
  2456. [ 2, 4 ],
  2457. 'fo<$text bold="true">ob</$text>ar',
  2458. 'fo{<strong>ob</strong>}ar'
  2459. );
  2460. } );
  2461. it( 'starts in text node, ends in attribute #1', () => {
  2462. testSelection(
  2463. [ 1, 3 ],
  2464. 'fo<$text bold="true">ob</$text>ar',
  2465. 'f{o<strong>o}b</strong>ar'
  2466. );
  2467. } );
  2468. it( 'starts in text node, ends in attribute #2', () => {
  2469. testSelection(
  2470. [ 1, 4 ],
  2471. 'fo<$text bold="true">ob</$text>ar',
  2472. 'f{o<strong>ob</strong>}ar'
  2473. );
  2474. } );
  2475. it( 'starts in attribute, ends in text node', () => {
  2476. testSelection(
  2477. [ 3, 5 ],
  2478. 'fo<$text bold="true">ob</$text>ar',
  2479. 'fo<strong>o{b</strong>a}r'
  2480. );
  2481. } );
  2482. it( 'consumes consumable values properly', () => {
  2483. // Add callback that will fire before default ones.
  2484. // This should prevent default callback doing anything.
  2485. dispatcher.on( 'selection', ( evt, data, conversionApi ) => {
  2486. expect( conversionApi.consumable.consume( data.selection, 'selection' ) ).to.be.true;
  2487. }, { priority: 'high' } );
  2488. // Similar test case as the first in this suite.
  2489. testSelection(
  2490. [ 1, 4 ],
  2491. 'foobar',
  2492. 'foobar' // No selection in view.
  2493. );
  2494. } );
  2495. it( 'should convert backward selection', () => {
  2496. testSelection(
  2497. [ 1, 3, 'backward' ],
  2498. 'foobar',
  2499. 'f{oo}bar'
  2500. );
  2501. expect( viewSelection.focus.offset ).to.equal( 1 );
  2502. } );
  2503. } );
  2504. describe( 'collapsed selection', () => {
  2505. let marker, viewDocument;
  2506. beforeEach( () => {
  2507. viewDocument = new ViewDocument( new StylesProcessor() );
  2508. } );
  2509. it( 'in container', () => {
  2510. testSelection(
  2511. [ 1, 1 ],
  2512. 'foobar',
  2513. 'f{}oobar'
  2514. );
  2515. } );
  2516. it( 'in attribute', () => {
  2517. testSelection(
  2518. [ 3, 3 ],
  2519. 'f<$text bold="true">ooba</$text>r',
  2520. 'f<strong>oo{}ba</strong>r'
  2521. );
  2522. } );
  2523. it( 'in attribute and marker', () => {
  2524. setModelData( model, 'fo<$text bold="true">ob</$text>ar' );
  2525. model.change( writer => {
  2526. const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 5 ) );
  2527. marker = writer.addMarker( 'marker', { range, usingOperation: false } );
  2528. writer.setSelection( modelRoot, 3 );
  2529. } );
  2530. // Remove view children manually (without firing additional conversion).
  2531. viewRoot._removeChildren( 0, viewRoot.childCount );
  2532. // Convert model to view.
  2533. view.change( writer => {
  2534. dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer );
  2535. dispatcher.convertMarkerAdd( marker.name, marker.getRange(), writer );
  2536. dispatcher.convertSelection( docSelection, model.markers, writer );
  2537. } );
  2538. // Stringify view and check if it is same as expected.
  2539. expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ).to.equal(
  2540. '<div>f<span class="marker">o<strong>o{}b</strong>a</span>r</div>'
  2541. );
  2542. } );
  2543. it( 'in attribute and marker - no attribute', () => {
  2544. setModelData( model, 'fo<$text bold="true">ob</$text>ar' );
  2545. model.change( writer => {
  2546. const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 5 ) );
  2547. marker = writer.addMarker( 'marker', { range, usingOperation: false } );
  2548. writer.setSelection( modelRoot, 3 );
  2549. writer.removeSelectionAttribute( 'bold' );
  2550. } );
  2551. // Remove view children manually (without firing additional conversion).
  2552. viewRoot._removeChildren( 0, viewRoot.childCount );
  2553. // Convert model to view.
  2554. view.change( writer => {
  2555. dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer );
  2556. dispatcher.convertMarkerAdd( marker.name, marker.getRange(), writer );
  2557. dispatcher.convertSelection( docSelection, model.markers, writer );
  2558. } );
  2559. // Stringify view and check if it is same as expected.
  2560. expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
  2561. .to.equal( '<div>f<span class="marker">o<strong>o</strong>[]<strong>b</strong>a</span>r</div>' );
  2562. } );
  2563. it( 'in marker - using highlight descriptor creator', () => {
  2564. downcastHelpers.markerToHighlight( {
  2565. model: 'marker2',
  2566. view: data => ( { classes: data.markerName } )
  2567. } );
  2568. setModelData( model, 'foobar' );
  2569. model.change( writer => {
  2570. const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 5 ) );
  2571. marker = writer.addMarker( 'marker2', { range, usingOperation: false } );
  2572. writer.setSelection( modelRoot, 3 );
  2573. } );
  2574. // Remove view children manually (without firing additional conversion).
  2575. viewRoot._removeChildren( 0, viewRoot.childCount );
  2576. // Convert model to view.
  2577. view.change( writer => {
  2578. dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer );
  2579. dispatcher.convertMarkerAdd( marker.name, marker.getRange(), writer );
  2580. dispatcher.convertSelection( docSelection, model.markers, writer );
  2581. } );
  2582. // Stringify view and check if it is same as expected.
  2583. expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
  2584. .to.equal( '<div>f<span class="marker2">oo{}ba</span>r</div>' );
  2585. } );
  2586. it( 'should do nothing if creator return null', () => {
  2587. downcastHelpers.markerToHighlight( {
  2588. model: 'marker3',
  2589. view: () => null
  2590. } );
  2591. setModelData( model, 'foobar' );
  2592. model.change( writer => {
  2593. const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 5 ) );
  2594. marker = writer.addMarker( 'marker3', { range, usingOperation: false } );
  2595. writer.setSelection( modelRoot, 3 );
  2596. } );
  2597. // Remove view children manually (without firing additional conversion).
  2598. viewRoot._removeChildren( 0, viewRoot.childCount );
  2599. // Convert model to view.
  2600. view.change( writer => {
  2601. dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer );
  2602. dispatcher.convertMarkerAdd( marker.name, marker.getRange(), writer );
  2603. dispatcher.convertSelection( docSelection, model.markers, writer );
  2604. } );
  2605. // Stringify view and check if it is same as expected.
  2606. expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
  2607. .to.equal( '<div>foo{}bar</div>' );
  2608. } );
  2609. // #1072 - if the container has only ui elements, collapsed selection attribute should be rendered after those ui elements.
  2610. it( 'selection with attribute before ui element - no non-ui children', () => {
  2611. setModelData( model, '' );
  2612. // Add two ui elements to view.
  2613. viewRoot._appendChild( [
  2614. new ViewUIElement( viewDocument, 'span' ),
  2615. new ViewUIElement( viewDocument, 'span' )
  2616. ] );
  2617. model.change( writer => {
  2618. writer.setSelection( writer.createRange( writer.createPositionFromPath( modelRoot, [ 0 ] ) ) );
  2619. writer.setSelectionAttribute( 'bold', true );
  2620. } );
  2621. // Convert model to view.
  2622. view.change( writer => {
  2623. dispatcher.convertSelection( docSelection, model.markers, writer );
  2624. } );
  2625. // Stringify view and check if it is same as expected.
  2626. expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
  2627. .to.equal( '<div><span></span><span></span><strong>[]</strong></div>' );
  2628. } );
  2629. // #1072.
  2630. it( 'selection with attribute before ui element - has non-ui children #1', () => {
  2631. setModelData( model, 'x' );
  2632. model.change( writer => {
  2633. writer.setSelection( writer.createRange( writer.createPositionFromPath( modelRoot, [ 1 ] ) ) );
  2634. writer.setSelectionAttribute( 'bold', true );
  2635. } );
  2636. // Convert model to view.
  2637. view.change( writer => {
  2638. dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer );
  2639. // Add ui element to view.
  2640. const uiElement = new ViewUIElement( viewDocument, 'span' );
  2641. viewRoot._insertChild( 1, uiElement );
  2642. dispatcher.convertSelection( docSelection, model.markers, writer );
  2643. } );
  2644. // Stringify view and check if it is same as expected.
  2645. expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
  2646. .to.equal( '<div>x<strong>[]</strong><span></span></div>' );
  2647. } );
  2648. // #1072.
  2649. it( 'selection with attribute before ui element - has non-ui children #2', () => {
  2650. setModelData( model, '<$text bold="true">x</$text>y' );
  2651. model.change( writer => {
  2652. writer.setSelection( writer.createRange( writer.createPositionFromPath( modelRoot, [ 1 ] ) ) );
  2653. writer.setSelectionAttribute( 'bold', true );
  2654. } );
  2655. // Convert model to view.
  2656. view.change( writer => {
  2657. dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer );
  2658. // Add ui element to view.
  2659. const uiElement = new ViewUIElement( viewDocument, 'span' );
  2660. viewRoot._insertChild( 1, uiElement, writer );
  2661. dispatcher.convertSelection( docSelection, model.markers, writer );
  2662. } );
  2663. // Stringify view and check if it is same as expected.
  2664. expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
  2665. .to.equal( '<div><strong>x{}</strong><span></span>y</div>' );
  2666. } );
  2667. it( 'consumes consumable values properly', () => {
  2668. // Add callbacks that will fire before default ones.
  2669. // This should prevent default callbacks doing anything.
  2670. dispatcher.on( 'selection', ( evt, data, conversionApi ) => {
  2671. expect( conversionApi.consumable.consume( data.selection, 'selection' ) ).to.be.true;
  2672. }, { priority: 'high' } );
  2673. dispatcher.on( 'attribute:bold', ( evt, data, conversionApi ) => {
  2674. expect( conversionApi.consumable.consume( data.item, 'attribute:bold' ) ).to.be.true;
  2675. }, { priority: 'high' } );
  2676. // Similar test case as above.
  2677. testSelection(
  2678. [ 3, 3 ],
  2679. 'f<$text bold="true">ooba</$text>r',
  2680. 'foobar' // No selection in view and no attribute.
  2681. );
  2682. } );
  2683. } );
  2684. } );
  2685. describe( 'clean-up', () => {
  2686. describe( 'convertRangeSelection', () => {
  2687. it( 'should remove all ranges before adding new range', () => {
  2688. testSelection(
  2689. [ 0, 2 ],
  2690. 'foobar',
  2691. '{fo}obar'
  2692. );
  2693. testSelection(
  2694. [ 3, 5 ],
  2695. 'foobar',
  2696. 'foo{ba}r'
  2697. );
  2698. expect( viewSelection.rangeCount ).to.equal( 1 );
  2699. } );
  2700. } );
  2701. describe( 'convertCollapsedSelection', () => {
  2702. it( 'should remove all ranges before adding new range', () => {
  2703. testSelection(
  2704. [ 2, 2 ],
  2705. 'foobar',
  2706. 'fo{}obar'
  2707. );
  2708. testSelection(
  2709. [ 3, 3 ],
  2710. 'foobar',
  2711. 'foo{}bar'
  2712. );
  2713. expect( viewSelection.rangeCount ).to.equal( 1 );
  2714. } );
  2715. } );
  2716. describe( 'clearAttributes', () => {
  2717. it( 'should remove all ranges before adding new range', () => {
  2718. testSelection(
  2719. [ 3, 3 ],
  2720. 'foobar',
  2721. 'foo<strong>[]</strong>bar',
  2722. { bold: 'true' }
  2723. );
  2724. view.change( writer => {
  2725. const modelRange = model.createRange( model.createPositionAt( modelRoot, 1 ), model.createPositionAt( modelRoot, 1 ) );
  2726. model.change( writer => {
  2727. writer.setSelection( modelRange );
  2728. } );
  2729. dispatcher.convertSelection( modelDoc.selection, model.markers, writer );
  2730. } );
  2731. expect( viewSelection.rangeCount ).to.equal( 1 );
  2732. const viewString = stringifyView( viewRoot, viewSelection, { showType: false } );
  2733. expect( viewString ).to.equal( '<div>f{}oobar</div>' );
  2734. } );
  2735. it( 'should do nothing if the attribute element had been already removed', () => {
  2736. testSelection(
  2737. [ 3, 3 ],
  2738. 'foobar',
  2739. 'foo<strong>[]</strong>bar',
  2740. { bold: 'true' }
  2741. );
  2742. view.change( writer => {
  2743. // Remove <strong></strong> manually.
  2744. writer.mergeAttributes( viewSelection.getFirstPosition() );
  2745. const modelRange = model.createRange( model.createPositionAt( modelRoot, 1 ), model.createPositionAt( modelRoot, 1 ) );
  2746. model.change( writer => {
  2747. writer.setSelection( modelRange );
  2748. } );
  2749. dispatcher.convertSelection( modelDoc.selection, model.markers, writer );
  2750. } );
  2751. expect( viewSelection.rangeCount ).to.equal( 1 );
  2752. const viewString = stringifyView( viewRoot, viewSelection, { showType: false } );
  2753. expect( viewString ).to.equal( '<div>f{}oobar</div>' );
  2754. } );
  2755. it( 'should clear fake selection', () => {
  2756. const modelRange = model.createRange( model.createPositionAt( modelRoot, 1 ), model.createPositionAt( modelRoot, 1 ) );
  2757. view.change( writer => {
  2758. writer.setSelection( modelRange, { fake: true } );
  2759. dispatcher.convertSelection( docSelection, model.markers, writer );
  2760. } );
  2761. expect( viewSelection.isFake ).to.be.false;
  2762. } );
  2763. } );
  2764. } );
  2765. describe( 'table cell selection converter', () => {
  2766. beforeEach( () => {
  2767. model.schema.register( 'table', { isLimit: true } );
  2768. model.schema.register( 'tr', { isLimit: true } );
  2769. model.schema.register( 'td', { isLimit: true } );
  2770. model.schema.extend( 'table', { allowIn: '$root' } );
  2771. model.schema.extend( 'tr', { allowIn: 'table' } );
  2772. model.schema.extend( 'td', { allowIn: 'tr' } );
  2773. model.schema.extend( '$text', { allowIn: 'td' } );
  2774. const downcastHelpers = new DowncastHelpers( [ dispatcher ] );
  2775. // "Universal" converter to convert table structure.
  2776. downcastHelpers.elementToElement( { model: 'table', view: 'table' } );
  2777. downcastHelpers.elementToElement( { model: 'tr', view: 'tr' } );
  2778. downcastHelpers.elementToElement( { model: 'td', view: 'td' } );
  2779. // Special converter for table cells.
  2780. dispatcher.on( 'selection', ( evt, data, conversionApi ) => {
  2781. const selection = data.selection;
  2782. if ( !conversionApi.consumable.test( selection, 'selection' ) || selection.isCollapsed ) {
  2783. return;
  2784. }
  2785. for ( const range of selection.getRanges() ) {
  2786. const node = range.start.parent;
  2787. if ( !!node && node.is( 'element', 'td' ) ) {
  2788. conversionApi.consumable.consume( selection, 'selection' );
  2789. const viewNode = conversionApi.mapper.toViewElement( node );
  2790. conversionApi.writer.addClass( 'selected', viewNode );
  2791. }
  2792. }
  2793. }, { priority: 'high' } );
  2794. } );
  2795. it( 'should not be used to convert selection that is not on table cell', () => {
  2796. testSelection(
  2797. [ 1, 5 ],
  2798. 'f{o<$text bold="true">ob</$text>a}r',
  2799. 'f{o<strong>ob</strong>a}r'
  2800. );
  2801. } );
  2802. it( 'should add a class to the selected table cell', () => {
  2803. testSelection(
  2804. // table tr#0 td#0 [foo, table tr#0 td#0 bar]
  2805. [ [ 0, 0, 0, 0 ], [ 0, 0, 0, 3 ] ],
  2806. '<table><tr><td>foo</td></tr><tr><td>bar</td></tr></table>',
  2807. '<table><tr><td class="selected">foo</td></tr><tr><td>bar</td></tr></table>'
  2808. );
  2809. } );
  2810. it( 'should not be used if selection contains more than just a table cell', () => {
  2811. testSelection(
  2812. // table tr td#1 f{oo bar, table tr#2 bar]
  2813. [ [ 0, 0, 0, 1 ], [ 0, 0, 1, 3 ] ],
  2814. '<table><tr><td>foo</td><td>bar</td></tr></table>',
  2815. '[<table><tr><td>foo</td><td>bar</td></tr></table>]'
  2816. );
  2817. } );
  2818. } );
  2819. // Tests if the selection got correctly converted.
  2820. // Because `setData` might use selection converters itself to set the selection, we can't use it
  2821. // to set the selection (because then we would test converters using converters).
  2822. // Instead, the `test` function expects to be passed `selectionPaths` which is an array containing two numbers or two arrays,
  2823. // that are offsets or paths of selection positions in root element.
  2824. function testSelection( selectionPaths, modelInput, expectedView, selectionAttributes = {} ) {
  2825. // Parse passed `modelInput` string and set it as current model.
  2826. setModelData( model, modelInput );
  2827. // Manually set selection ranges using passed `selectionPaths`.
  2828. const startPath = typeof selectionPaths[ 0 ] == 'number' ? [ selectionPaths[ 0 ] ] : selectionPaths[ 0 ];
  2829. const endPath = typeof selectionPaths[ 1 ] == 'number' ? [ selectionPaths[ 1 ] ] : selectionPaths[ 1 ];
  2830. const startPos = model.createPositionFromPath( modelRoot, startPath );
  2831. const endPos = model.createPositionFromPath( modelRoot, endPath );
  2832. const isBackward = selectionPaths[ 2 ] === 'backward';
  2833. model.change( writer => {
  2834. writer.setSelection( writer.createRange( startPos, endPos ), { backward: isBackward } );
  2835. // And add or remove passed attributes.
  2836. for ( const key in selectionAttributes ) {
  2837. const value = selectionAttributes[ key ];
  2838. if ( value ) {
  2839. writer.setSelectionAttribute( key, value );
  2840. } else {
  2841. writer.removeSelectionAttribute( key );
  2842. }
  2843. }
  2844. } );
  2845. // Remove view children manually (without firing additional conversion).
  2846. viewRoot._removeChildren( 0, viewRoot.childCount );
  2847. // Convert model to view.
  2848. view.change( writer => {
  2849. dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer );
  2850. dispatcher.convertSelection( docSelection, model.markers, writer );
  2851. } );
  2852. // Stringify view and check if it is same as expected.
  2853. expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ).to.equal( '<div>' + expectedView + '</div>' );
  2854. }
  2855. } );
  2856. function viewToString( item ) {
  2857. let result = '';
  2858. if ( item instanceof ViewText ) {
  2859. result = item.data;
  2860. } else {
  2861. // ViewElement or ViewDocumentFragment.
  2862. for ( const child of item.getChildren() ) {
  2863. result += viewToString( child );
  2864. }
  2865. if ( item instanceof ViewElement ) {
  2866. result = '<' + item.name + viewAttributesToString( item ) + '>' + result + '</' + item.name + '>';
  2867. }
  2868. }
  2869. return result;
  2870. }
  2871. function viewAttributesToString( item ) {
  2872. let result = '';
  2873. for ( const key of item.getAttributeKeys() ) {
  2874. const value = item.getAttribute( key );
  2875. if ( value ) {
  2876. result += ' ' + key + '="' + value + '"';
  2877. }
  2878. }
  2879. return result;
  2880. }