8
0

modelconversiondispatcher.js 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907
  1. /**
  2. * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. import ModelConversionDispatcher from '../../src/conversion/modelconversiondispatcher';
  6. import Model from '../../src/model/model';
  7. import ModelText from '../../src/model/text';
  8. import ModelElement from '../../src/model/element';
  9. import ModelRange from '../../src/model/range';
  10. import ModelPosition from '../../src/model/position';
  11. import ModelWriter from '../../src/model/writer';
  12. import Batch from '../../src/model/batch';
  13. import RemoveOperation from '../../src/model/operation/removeoperation';
  14. import NoOperation from '../../src/model/operation/nooperation';
  15. import RenameOperation from '../../src/model/operation/renameoperation';
  16. import AttributeOperation from '../../src/model/operation/attributeoperation';
  17. import { wrapInDelta } from '../../tests/model/_utils/utils';
  18. import ViewContainerElement from '../../src/view/containerelement';
  19. describe( 'ModelConversionDispatcher', () => {
  20. let dispatcher, doc, root, gyPos, model, writer;
  21. beforeEach( () => {
  22. model = new Model();
  23. doc = model.document;
  24. dispatcher = new ModelConversionDispatcher( model );
  25. root = doc.createRoot();
  26. gyPos = new ModelPosition( doc.graveyard, [ 0 ] );
  27. // As an util for modifying model tree.
  28. writer = new ModelWriter( model, new Batch() );
  29. } );
  30. describe( 'constructor()', () => {
  31. it( 'should create ModelConversionDispatcher with given api', () => {
  32. const apiObj = {};
  33. const dispatcher = new ModelConversionDispatcher( model, { apiObj } );
  34. expect( dispatcher.conversionApi.apiObj ).to.equal( apiObj );
  35. } );
  36. } );
  37. describe( 'convertChange', () => {
  38. // We will do integration tests here. Unit tests will be done for methods that are used
  39. // by `convertChange` internally. This way we will have two kinds of tests.
  40. let image, imagePos;
  41. beforeEach( () => {
  42. image = new ModelElement( 'image' );
  43. root.appendChildren( [ image, new ModelText( 'foobar' ) ] );
  44. imagePos = ModelPosition.createBefore( image );
  45. dispatcher.listenTo( doc, 'change', ( evt, type, changes ) => {
  46. dispatcher.convertChange( type, changes );
  47. } );
  48. } );
  49. it( 'should fire insert and addAttribute callbacks for insertion changes', () => {
  50. const cbInsertText = sinon.spy();
  51. const cbInsertImage = sinon.spy();
  52. const cbAddAttribute = sinon.spy();
  53. dispatcher.on( 'insert:$text', cbInsertText );
  54. dispatcher.on( 'insert:image', cbInsertImage );
  55. dispatcher.on( 'addAttribute:key:$text', cbAddAttribute );
  56. model.change( writer => {
  57. writer.insertText( 'foo', { key: 'value' }, root );
  58. } );
  59. expect( cbInsertText.called ).to.be.true;
  60. expect( cbAddAttribute.called ).to.be.true;
  61. expect( cbInsertImage.called ).to.be.false;
  62. } );
  63. it( 'should fire insert and addAttribute callbacks for reinsertion changes', () => {
  64. image.setAttribute( 'key', 'value' );
  65. // We will just create reinsert operation by reverting remove operation
  66. // because creating reinsert change is tricky and not available through batch API.
  67. const removeOperation = new RemoveOperation( imagePos, 1, gyPos, 0 );
  68. // Let's apply remove operation so reinsert operation won't break.
  69. model.applyOperation( wrapInDelta( removeOperation ) );
  70. const cbInsertText = sinon.spy();
  71. const cbInsertImage = sinon.spy();
  72. const cbAddAttribute = sinon.spy();
  73. dispatcher.on( 'insert:$text', cbInsertText );
  74. dispatcher.on( 'insert:image', cbInsertImage );
  75. dispatcher.on( 'addAttribute:key:image', cbAddAttribute );
  76. model.applyOperation( wrapInDelta( removeOperation.getReversed() ) );
  77. expect( cbInsertImage.called ).to.be.true;
  78. expect( cbAddAttribute.called ).to.be.true;
  79. expect( cbInsertText.called ).to.be.false;
  80. } );
  81. it( 'should fire remove callback for remove changes', () => {
  82. const cbRemove = sinon.spy();
  83. dispatcher.on( 'remove', cbRemove );
  84. model.change( writer => {
  85. writer.remove( image );
  86. } );
  87. expect( cbRemove.called ).to.be.true;
  88. } );
  89. it( 'should fire addAttribute callbacks for add attribute change', () => {
  90. const cbAddText = sinon.spy();
  91. const cbAddImage = sinon.spy();
  92. dispatcher.on( 'addAttribute:key:$text', cbAddText );
  93. dispatcher.on( 'addAttribute:key:image', cbAddImage );
  94. model.change( writer => {
  95. writer.setAttribute( 'key', 'value', image );
  96. } );
  97. // Callback for adding attribute on text not called.
  98. expect( cbAddText.called ).to.be.false;
  99. expect( cbAddImage.calledOnce ).to.be.true;
  100. model.change( writer => {
  101. writer.setAttribute( 'key', 'value', ModelRange.createFromParentsAndOffsets( root, 3, root, 4 ) );
  102. } );
  103. expect( cbAddText.calledOnce ).to.be.true;
  104. // Callback for adding attribute on image not called this time.
  105. expect( cbAddImage.calledOnce ).to.be.true;
  106. } );
  107. it( 'should fire changeAttribute callbacks for change attribute change', () => {
  108. const cbChangeText = sinon.spy();
  109. const cbChangeImage = sinon.spy();
  110. dispatcher.on( 'changeAttribute:key:$text', cbChangeText );
  111. dispatcher.on( 'changeAttribute:key:image', cbChangeImage );
  112. model.change( writer => {
  113. writer.setAttribute( 'key', 'value', image );
  114. writer.setAttribute( 'key', 'newValue', image );
  115. } );
  116. // Callback for adding attribute on text not called.
  117. expect( cbChangeText.called ).to.be.false;
  118. expect( cbChangeImage.calledOnce ).to.be.true;
  119. const range = ModelRange.createFromParentsAndOffsets( root, 3, root, 4 );
  120. model.change( writer => {
  121. writer.setAttribute( 'key', 'value', range );
  122. writer.setAttribute( 'key', 'newValue', range );
  123. } );
  124. expect( cbChangeText.calledOnce ).to.be.true;
  125. // Callback for adding attribute on image not called this time.
  126. expect( cbChangeImage.calledOnce ).to.be.true;
  127. } );
  128. it( 'should fire removeAttribute callbacks for remove attribute change', () => {
  129. const cbRemoveText = sinon.spy();
  130. const cbRemoveImage = sinon.spy();
  131. dispatcher.on( 'removeAttribute:key:$text', cbRemoveText );
  132. dispatcher.on( 'removeAttribute:key:image', cbRemoveImage );
  133. writer.setAttribute( 'key', 'value', image );
  134. writer.removeAttribute( 'key', image );
  135. // Callback for adding attribute on text not called.
  136. expect( cbRemoveText.called ).to.be.false;
  137. expect( cbRemoveImage.calledOnce ).to.be.true;
  138. const range = ModelRange.createFromParentsAndOffsets( root, 3, root, 4 );
  139. writer.setAttribute( 'key', 'value', range );
  140. writer.removeAttribute( 'key', range );
  141. expect( cbRemoveText.calledOnce ).to.be.true;
  142. // Callback for adding attribute on image not called this time.
  143. expect( cbRemoveImage.calledOnce ).to.be.true;
  144. } );
  145. it( 'should not fire any event if not recognized event type was passed', () => {
  146. sinon.spy( dispatcher, 'fire' );
  147. dispatcher.convertChange( 'unknown', { foo: 'bar' } );
  148. expect( dispatcher.fire.called ).to.be.false;
  149. } );
  150. it( 'should not fire any event if changed range in graveyard root and change type is different than remove', () => {
  151. sinon.spy( dispatcher, 'fire' );
  152. const gyNode = new ModelElement( 'image' );
  153. doc.graveyard.appendChildren( gyNode );
  154. writer.setAttribute( 'key', 'value', gyNode );
  155. expect( dispatcher.fire.called ).to.be.false;
  156. } );
  157. it( 'should not fire any event if remove operation moves nodes between graveyard holders', () => {
  158. // This may happen during OT.
  159. sinon.spy( dispatcher, 'fire' );
  160. const gyNode = new ModelElement( 'image' );
  161. doc.graveyard.appendChildren( gyNode );
  162. writer.remove( gyNode );
  163. expect( dispatcher.fire.called ).to.be.false;
  164. } );
  165. it( 'should not fire any event if element in graveyard was removed', () => {
  166. // This may happen during OT.
  167. sinon.spy( dispatcher, 'fire' );
  168. const gyNode = new ModelElement( 'image' );
  169. doc.graveyard.appendChildren( gyNode );
  170. writer.rename( gyNode, 'p' );
  171. expect( dispatcher.fire.called ).to.be.false;
  172. } );
  173. it( 'should not fire any event after NoOperation is applied', () => {
  174. sinon.spy( dispatcher, 'fire' );
  175. model.applyOperation( wrapInDelta( new NoOperation( 0 ) ) );
  176. expect( dispatcher.fire.called ).to.be.false;
  177. } );
  178. it( 'should not fire any event after RenameOperation with same old and new value is applied', () => {
  179. sinon.spy( dispatcher, 'fire' );
  180. root.removeChildren( 0, root.childCount );
  181. root.appendChildren( [ new ModelElement( 'paragraph' ) ] );
  182. model.applyOperation( wrapInDelta( new RenameOperation( new ModelPosition( root, [ 0 ] ), 'paragraph', 'paragraph', 0 ) ) );
  183. expect( dispatcher.fire.called ).to.be.false;
  184. } );
  185. it( 'should not fire any event after AttributeOperation with same old an new value is applied', () => {
  186. sinon.spy( dispatcher, 'fire' );
  187. root.removeChildren( 0, root.childCount );
  188. root.appendChildren( [ new ModelElement( 'paragraph', { foo: 'bar' } ) ] );
  189. const range = new ModelRange( new ModelPosition( root, [ 0 ] ), new ModelPosition( root, [ 0, 0 ] ) );
  190. model.applyOperation( wrapInDelta( new AttributeOperation( range, 'foo', 'bar', 'bar', 0 ) ) );
  191. expect( dispatcher.fire.called ).to.be.false;
  192. } );
  193. } );
  194. describe( 'convertInsert', () => {
  195. it( 'should fire event with correct parameters for every item in passed range', () => {
  196. root.appendChildren( [
  197. new ModelText( 'foo', { bold: true } ),
  198. new ModelElement( 'image' ),
  199. new ModelText( 'bar' ),
  200. new ModelElement( 'paragraph', { class: 'nice' }, new ModelText( 'xx', { italic: true } ) )
  201. ] );
  202. const range = ModelRange.createIn( root );
  203. const loggedEvents = [];
  204. // We will check everything connected with insert event:
  205. dispatcher.on( 'insert', ( evt, data, consumable ) => {
  206. // Check if the item is correct.
  207. const itemId = data.item.name ? data.item.name : '$text:' + data.item.data;
  208. // Check if the range is correct.
  209. const log = 'insert:' + itemId + ':' + data.range.start.path + ':' + data.range.end.path;
  210. loggedEvents.push( log );
  211. // Check if the event name is correct.
  212. expect( evt.name ).to.equal( 'insert:' + ( data.item.name || '$text' ) );
  213. // Check if model consumable is correct.
  214. expect( consumable.consume( data.item, 'insert' ) ).to.be.true;
  215. } );
  216. // Same here.
  217. dispatcher.on( 'addAttribute', ( evt, data, consumable ) => {
  218. const itemId = data.item.name ? data.item.name : '$text:' + data.item.data;
  219. const key = data.attributeKey;
  220. const value = data.attributeNewValue;
  221. const log = 'addAttribute:' + key + ':' + value + ':' + itemId + ':' + data.range.start.path + ':' + data.range.end.path;
  222. loggedEvents.push( log );
  223. expect( evt.name ).to.equal( 'addAttribute:' + key + ':' + ( data.item.name || '$text' ) );
  224. expect( consumable.consume( data.item, 'addAttribute:' + key ) ).to.be.true;
  225. } );
  226. dispatcher.convertInsertion( range );
  227. // Check the data passed to called events and the order of them.
  228. expect( loggedEvents ).to.deep.equal( [
  229. 'insert:$text:foo:0:3',
  230. 'addAttribute:bold:true:$text:foo:0:3',
  231. 'insert:image:3:4',
  232. 'insert:$text:bar:4:7',
  233. 'insert:paragraph:7:8',
  234. 'addAttribute:class:nice:paragraph:7:8',
  235. 'insert:$text:xx:7,0:7,2',
  236. 'addAttribute:italic:true:$text:xx:7,0:7,2'
  237. ] );
  238. } );
  239. it( 'should not fire events for already consumed parts of model', () => {
  240. root.appendChildren( [
  241. new ModelElement( 'image', { src: 'foo.jpg', title: 'bar', bold: true }, [
  242. new ModelElement( 'caption', {}, new ModelText( 'title' ) )
  243. ] )
  244. ] );
  245. sinon.spy( dispatcher, 'fire' );
  246. dispatcher.on( 'insert:image', ( evt, data, consumable ) => {
  247. consumable.consume( data.item.getChild( 0 ), 'insert' );
  248. consumable.consume( data.item, 'addAttribute:bold' );
  249. } );
  250. const range = ModelRange.createIn( root );
  251. dispatcher.convertInsertion( range );
  252. expect( dispatcher.fire.calledWith( 'insert:image' ) ).to.be.true;
  253. expect( dispatcher.fire.calledWith( 'addAttribute:src:image' ) ).to.be.true;
  254. expect( dispatcher.fire.calledWith( 'addAttribute:title:image' ) ).to.be.true;
  255. expect( dispatcher.fire.calledWith( 'insert:$text' ) ).to.be.true;
  256. expect( dispatcher.fire.calledWith( 'addAttribute:bold:image' ) ).to.be.false;
  257. expect( dispatcher.fire.calledWith( 'insert:caption' ) ).to.be.false;
  258. } );
  259. it( 'should fire marker converter if content is inserted into marker', () => {
  260. const convertMarkerSpy = sinon.spy( dispatcher, 'convertMarker' );
  261. const paragraph1 = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
  262. const paragraph2 = new ModelElement( 'paragraph', null, new ModelText( 'bar' ) );
  263. root.appendChildren( [ paragraph1, paragraph2 ] );
  264. const markerRange = ModelRange.createFromParentsAndOffsets( root, 0, root, 2 );
  265. model.markers.set( 'marker', markerRange );
  266. const insertionRange = ModelRange.createOn( paragraph2 );
  267. dispatcher.convertInsertion( insertionRange );
  268. sinon.assert.calledOnce( convertMarkerSpy );
  269. const callArgs = convertMarkerSpy.args[ 0 ];
  270. expect( callArgs[ 0 ] ).to.equal( 'addMarker' );
  271. expect( callArgs[ 1 ] ).to.equal( 'marker' );
  272. expect( callArgs[ 2 ].isEqual( markerRange.getIntersection( insertionRange ) ) ).to.be.true;
  273. } );
  274. it( 'should fire marker converter if content has marker', () => {
  275. const convertMarkerSpy = sinon.spy( dispatcher, 'convertMarker' );
  276. const paragraph1 = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
  277. const paragraph2 = new ModelElement( 'paragraph', null, new ModelText( 'bar' ) );
  278. root.appendChildren( [ paragraph1, paragraph2 ] );
  279. const markerRange = ModelRange.createIn( paragraph2 );
  280. model.markers.set( 'marker', markerRange );
  281. const insertionRange = ModelRange.createOn( paragraph2 );
  282. dispatcher.convertInsertion( insertionRange );
  283. sinon.assert.calledOnce( convertMarkerSpy );
  284. const callArgs = convertMarkerSpy.args[ 0 ];
  285. expect( callArgs[ 0 ] ).to.equal( 'addMarker' );
  286. expect( callArgs[ 1 ] ).to.equal( 'marker' );
  287. expect( callArgs[ 2 ].isEqual( markerRange ) ).to.be.true;
  288. } );
  289. it( 'should not fire marker conversion if content is inserted into element with custom highlight handling', () => {
  290. sinon.spy( dispatcher, 'convertMarker' );
  291. const text = new ModelText( 'abc' );
  292. const caption = new ModelElement( 'caption', null, text );
  293. const image = new ModelElement( 'image', null, caption );
  294. root.appendChildren( [ image ] );
  295. // Create view elements that will be "mapped" to model elements.
  296. const viewCaption = new ViewContainerElement( 'caption' );
  297. const viewFigure = new ViewContainerElement( 'figure', null, viewCaption );
  298. // Create custom highlight handler mock.
  299. viewFigure.setCustomProperty( 'addHighlight', () => {} );
  300. viewFigure.setCustomProperty( 'removeHighlight', () => {} );
  301. // Create mapper mock.
  302. dispatcher.conversionApi.mapper = {
  303. toViewElement( modelElement ) {
  304. if ( modelElement == image ) {
  305. return viewFigure;
  306. } else if ( modelElement == caption ) {
  307. return viewCaption;
  308. }
  309. }
  310. };
  311. const markerRange = ModelRange.createFromParentsAndOffsets( root, 0, root, 1 );
  312. model.markers.set( 'marker', markerRange );
  313. const insertionRange = ModelRange.createFromParentsAndOffsets( caption, 1, caption, 2 );
  314. dispatcher.convertInsertion( insertionRange );
  315. expect( dispatcher.convertMarker.called ).to.be.false;
  316. } );
  317. it( 'should fire marker conversion if inserted into element with highlight handling but element is not in marker range', () => {
  318. sinon.spy( dispatcher, 'convertMarker' );
  319. const text = new ModelText( 'abc' );
  320. const caption = new ModelElement( 'caption', null, text );
  321. const image = new ModelElement( 'image', null, caption );
  322. root.appendChildren( [ image ] );
  323. // Create view elements that will be "mapped" to model elements.
  324. const viewCaption = new ViewContainerElement( 'caption' );
  325. const viewFigure = new ViewContainerElement( 'figure', null, viewCaption );
  326. // Create custom highlight handler mock.
  327. viewFigure.setCustomProperty( 'addHighlight', () => {} );
  328. viewFigure.setCustomProperty( 'removeHighlight', () => {} );
  329. // Create mapper mock.
  330. dispatcher.conversionApi.mapper = {
  331. toViewElement( modelElement ) {
  332. if ( modelElement == image ) {
  333. return viewFigure;
  334. } else if ( modelElement == caption ) {
  335. return viewCaption;
  336. }
  337. }
  338. };
  339. const markerRange = ModelRange.createFromParentsAndOffsets( caption, 0, caption, 3 );
  340. model.markers.set( 'marker', markerRange );
  341. const insertionRange = ModelRange.createFromParentsAndOffsets( caption, 2, caption, 3 );
  342. dispatcher.convertInsertion( insertionRange );
  343. expect( dispatcher.convertMarker.called ).to.be.true;
  344. } );
  345. } );
  346. describe( 'convertMove', () => {
  347. let loggedEvents;
  348. beforeEach( () => {
  349. loggedEvents = [];
  350. dispatcher.on( 'remove', ( evt, data ) => {
  351. const log = 'remove:' + data.sourcePosition.path + ':' + data.item.offsetSize;
  352. loggedEvents.push( log );
  353. } );
  354. dispatcher.on( 'insert', ( evt, data ) => {
  355. const log = 'insert:' + data.range.start.path + ':' + data.range.end.path;
  356. loggedEvents.push( log );
  357. } );
  358. } );
  359. it( 'should first fire remove and then insert if moving "right"', () => {
  360. // <root>[ab]cd^ef</root> -> <root>cdabef</root>
  361. root.appendChildren( new ModelText( 'cdabef' ) );
  362. const sourcePosition = ModelPosition.createFromParentAndOffset( root, 0 );
  363. const movedRange = ModelRange.createFromParentsAndOffsets( root, 2, root, 4 );
  364. dispatcher.convertMove( sourcePosition, movedRange );
  365. // after remove: cdef
  366. // after insert: cd[ab]ef
  367. expect( loggedEvents ).to.deep.equal( [ 'remove:0:2', 'insert:2:4' ] );
  368. } );
  369. it( 'should first fire insert and then remove if moving "left"', () => {
  370. // <root>ab^cd[ef]</root> -> <root>abefcd</root>
  371. root.appendChildren( new ModelText( 'abefcd' ) );
  372. const sourcePosition = ModelPosition.createFromParentAndOffset( root, 4 );
  373. const movedRange = ModelRange.createFromParentsAndOffsets( root, 2, root, 4 );
  374. dispatcher.convertMove( sourcePosition, movedRange );
  375. // after insert: ab[ef]cd[ef]
  376. // after remove: ab[ef]cd
  377. expect( loggedEvents ).to.deep.equal( [ 'insert:2:4', 'remove:6:2' ] );
  378. } );
  379. it( 'should first fire insert and then remove when moving like in unwrap', () => {
  380. // <root>a^<w>[xyz]</w>b</root> -> <root>axyz<w></w>b</root>
  381. root.appendChildren( [
  382. new ModelText( 'axyz' ),
  383. new ModelElement( 'w' ),
  384. new ModelText( 'b' )
  385. ] );
  386. const sourcePosition = new ModelPosition( root, [ 1, 0 ] );
  387. const movedRange = ModelRange.createFromParentsAndOffsets( root, 1, root, 4 );
  388. dispatcher.convertMove( sourcePosition, movedRange );
  389. // before: a<w>[xyz]</w>b
  390. // after insert: a[xyz]<w>[xyz]</w>b
  391. // after remove: a[xyz]<w></w>b
  392. expect( loggedEvents ).to.deep.equal( [ 'insert:1:4', 'remove:4,0:3' ] );
  393. } );
  394. it( 'should first fire remove and then insert when moving like in wrap', () => {
  395. // <root>a[xyz]<w>^</w>b</root> -> <root>a<w>xyz</w>b</root>
  396. root.appendChildren( [
  397. new ModelText( 'a' ),
  398. new ModelElement( 'w', null, [ new ModelText( 'xyz' ) ] ),
  399. new ModelText( 'b' )
  400. ] );
  401. const sourcePosition = ModelPosition.createFromParentAndOffset( root, 1 );
  402. const movedRange = ModelRange.createFromPositionAndShift( new ModelPosition( root, [ 1, 0 ] ), 3 );
  403. dispatcher.convertMove( sourcePosition, movedRange );
  404. // before: a[xyz]<w></w>b
  405. // after remove: a<w></w>b
  406. // after insert: a<w>[xyz]</w>b
  407. expect( loggedEvents ).to.deep.equal( [ 'remove:1:3', 'insert:1,0:1,3' ] );
  408. } );
  409. } );
  410. describe( 'convertRemove', () => {
  411. it( 'should fire event for removed range', () => {
  412. root.appendChildren( new ModelText( 'foo' ) );
  413. doc.graveyard.appendChildren( new ModelText( 'bar' ) );
  414. const range = ModelRange.createFromParentsAndOffsets( doc.graveyard, 0, doc.graveyard, 3 );
  415. const loggedEvents = [];
  416. dispatcher.on( 'remove', ( evt, data ) => {
  417. const log = 'remove:' + data.sourcePosition.path + ':' + data.item.offsetSize;
  418. loggedEvents.push( log );
  419. } );
  420. dispatcher.convertRemove( ModelPosition.createFromParentAndOffset( root, 3 ), range );
  421. expect( loggedEvents ).to.deep.equal( [ 'remove:3:3' ] );
  422. } );
  423. } );
  424. describe( 'convertAttribute', () => {
  425. it( 'should fire event for every item in passed range', () => {
  426. root.appendChildren( [
  427. new ModelText( 'foo', { bold: true } ),
  428. new ModelElement( 'image', { bold: true } ),
  429. new ModelElement( 'paragraph', { bold: true, class: 'nice' }, new ModelText( 'xx', { bold: true, italic: true } ) )
  430. ] );
  431. const range = ModelRange.createIn( root );
  432. const loggedEvents = [];
  433. dispatcher.on( 'addAttribute', ( evt, data, consumable ) => {
  434. const itemId = data.item.name ? data.item.name : '$text:' + data.item.data;
  435. const key = data.attributeKey;
  436. const value = data.attributeNewValue;
  437. const log = 'addAttribute:' + key + ':' + value + ':' + itemId + ':' + data.range.start.path + ':' + data.range.end.path;
  438. loggedEvents.push( log );
  439. expect( evt.name ).to.equal( 'addAttribute:' + key + ':' + ( data.item.name || '$text' ) );
  440. expect( consumable.consume( data.item, 'addAttribute:' + key ) ).to.be.true;
  441. } );
  442. dispatcher.convertAttribute( 'addAttribute', range, 'bold', null, true );
  443. expect( loggedEvents ).to.deep.equal( [
  444. 'addAttribute:bold:true:$text:foo:0:3',
  445. 'addAttribute:bold:true:image:3:4',
  446. 'addAttribute:bold:true:paragraph:4:5',
  447. 'addAttribute:bold:true:$text:xx:4,0:4,2'
  448. ] );
  449. } );
  450. it( 'should not fire events for already consumed parts of model', () => {
  451. root.appendChildren( [
  452. new ModelElement( 'element', null, new ModelElement( 'inside' ) )
  453. ] );
  454. sinon.spy( dispatcher, 'fire' );
  455. dispatcher.on( 'removeAttribute:attr:element', ( evt, data, consumable ) => {
  456. consumable.consume( data.item.getChild( 0 ), 'removeAttribute:attr' );
  457. } );
  458. const range = ModelRange.createIn( root );
  459. dispatcher.convertAttribute( 'removeAttribute', range, 'attr', 'value', null );
  460. expect( dispatcher.fire.calledWith( 'removeAttribute:attr:element' ) ).to.be.true;
  461. expect( dispatcher.fire.calledWith( 'removeAttribute:attr:inside' ) ).to.be.false;
  462. } );
  463. } );
  464. describe( 'convertSelection', () => {
  465. beforeEach( () => {
  466. dispatcher.off( 'selection' );
  467. root.appendChildren( new ModelText( 'foobar' ) );
  468. doc.selection.setRanges( [
  469. new ModelRange( new ModelPosition( root, [ 1 ] ), new ModelPosition( root, [ 3 ] ) ),
  470. new ModelRange( new ModelPosition( root, [ 4 ] ), new ModelPosition( root, [ 5 ] ) )
  471. ] );
  472. } );
  473. it( 'should fire selection event', () => {
  474. sinon.spy( dispatcher, 'fire' );
  475. dispatcher.convertSelection( doc.selection, [] );
  476. expect( dispatcher.fire.calledWith(
  477. 'selection',
  478. { selection: sinon.match.instanceOf( doc.selection.constructor ) }
  479. ) ).to.be.true;
  480. } );
  481. it( 'should prepare correct list of consumable values', () => {
  482. model.enqueueChange( writer => {
  483. writer.setAttribute( 'bold', true, ModelRange.createIn( root ) );
  484. writer.setAttribute( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) );
  485. } );
  486. dispatcher.on( 'selection', ( evt, data, consumable ) => {
  487. expect( consumable.test( data.selection, 'selection' ) ).to.be.true;
  488. expect( consumable.test( data.selection, 'selectionAttribute:bold' ) ).to.be.true;
  489. expect( consumable.test( data.selection, 'selectionAttribute:italic' ) ).to.be.null;
  490. } );
  491. dispatcher.convertSelection( doc.selection, [] );
  492. } );
  493. it( 'should fire attributes events for selection', () => {
  494. sinon.spy( dispatcher, 'fire' );
  495. model.enqueueChange( writer => {
  496. writer.setAttribute( 'bold', true, ModelRange.createIn( root ) );
  497. writer.setAttribute( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) );
  498. } );
  499. dispatcher.convertSelection( doc.selection, [] );
  500. expect( dispatcher.fire.calledWith( 'selectionAttribute:bold' ) ).to.be.true;
  501. expect( dispatcher.fire.calledWith( 'selectionAttribute:italic' ) ).to.be.false;
  502. } );
  503. it( 'should not fire attributes events if attribute has been consumed', () => {
  504. sinon.spy( dispatcher, 'fire' );
  505. dispatcher.on( 'selection', ( evt, data, consumable ) => {
  506. consumable.consume( data.selection, 'selectionAttribute:bold' );
  507. } );
  508. model.enqueueChange( writer => {
  509. writer.setAttribute( 'bold', true, ModelRange.createIn( root ) );
  510. writer.setAttribute( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) );
  511. } );
  512. dispatcher.convertSelection( doc.selection, [] );
  513. expect( dispatcher.fire.calledWith( 'selectionAttribute:bold' ) ).to.be.false;
  514. } );
  515. it( 'should fire events for each marker which contains selection', () => {
  516. model.markers.set( 'name', ModelRange.createFromParentsAndOffsets( root, 0, root, 2 ) );
  517. sinon.spy( dispatcher, 'fire' );
  518. const markers = Array.from( model.markers.getMarkersAtPosition( doc.selection.getFirstPosition() ) );
  519. dispatcher.convertSelection( doc.selection, markers );
  520. expect( dispatcher.fire.calledWith( 'selectionMarker:name' ) ).to.be.true;
  521. } );
  522. it( 'should not fire event for marker if selection is in a element with custom highlight handling', () => {
  523. // Clear after `beforeEach`.
  524. root.removeChildren( 0, root.childCount );
  525. const text = new ModelText( 'abc' );
  526. const caption = new ModelElement( 'caption', null, text );
  527. const image = new ModelElement( 'image', null, caption );
  528. root.appendChildren( [ image ] );
  529. // Create view elements that will be "mapped" to model elements.
  530. const viewCaption = new ViewContainerElement( 'caption' );
  531. const viewFigure = new ViewContainerElement( 'figure', null, viewCaption );
  532. // Create custom highlight handler mock.
  533. viewFigure.setCustomProperty( 'addHighlight', () => {} );
  534. viewFigure.setCustomProperty( 'removeHighlight', () => {} );
  535. // Create mapper mock.
  536. dispatcher.conversionApi.mapper = {
  537. toViewElement( modelElement ) {
  538. if ( modelElement == image ) {
  539. return viewFigure;
  540. } else if ( modelElement == caption ) {
  541. return viewCaption;
  542. }
  543. }
  544. };
  545. model.markers.set( 'name', ModelRange.createFromParentsAndOffsets( root, 0, root, 1 ) );
  546. doc.selection.setRanges( [ ModelRange.createFromParentsAndOffsets( caption, 1, caption, 1 ) ] );
  547. sinon.spy( dispatcher, 'fire' );
  548. const markers = Array.from( model.markers.getMarkersAtPosition( doc.selection.getFirstPosition() ) );
  549. dispatcher.convertSelection( doc.selection, markers );
  550. expect( dispatcher.fire.calledWith( 'selectionMarker:name' ) ).to.be.false;
  551. } );
  552. it( 'should not fire events if information about marker has been consumed', () => {
  553. model.markers.set( 'foo', ModelRange.createFromParentsAndOffsets( root, 0, root, 2 ) );
  554. model.markers.set( 'bar', ModelRange.createFromParentsAndOffsets( root, 0, root, 2 ) );
  555. sinon.spy( dispatcher, 'fire' );
  556. dispatcher.on( 'selectionMarker:foo', ( evt, data, consumable ) => {
  557. consumable.consume( data.selection, 'selectionMarker:bar' );
  558. } );
  559. const markers = Array.from( model.markers.getMarkersAtPosition( doc.selection.getFirstPosition() ) );
  560. dispatcher.convertSelection( doc.selection, markers );
  561. expect( dispatcher.fire.calledWith( 'selectionMarker:foo' ) ).to.be.true;
  562. expect( dispatcher.fire.calledWith( 'selectionMarker:bar' ) ).to.be.false;
  563. } );
  564. } );
  565. describe( 'convertMarker', () => {
  566. let range;
  567. beforeEach( () => {
  568. const element = new ModelElement( 'paragraph', null, [ new ModelText( 'foo bar baz' ) ] );
  569. root.appendChildren( [ element ] );
  570. range = ModelRange.createFromParentsAndOffsets( element, 0, element, 4 );
  571. } );
  572. it( 'should fire event based on passed parameters', () => {
  573. sinon.spy( dispatcher, 'fire' );
  574. dispatcher.convertMarker( 'addMarker', 'name', range );
  575. expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.true;
  576. dispatcher.convertMarker( 'removeMarker', 'name', range );
  577. expect( dispatcher.fire.calledWith( 'removeMarker:name' ) ).to.be.true;
  578. } );
  579. it( 'should not convert marker if it is added in graveyard', () => {
  580. const gyRange = ModelRange.createFromParentsAndOffsets( doc.graveyard, 0, doc.graveyard, 0 );
  581. sinon.spy( dispatcher, 'fire' );
  582. dispatcher.convertMarker( 'addMarker', 'name', gyRange );
  583. expect( dispatcher.fire.called ).to.be.false;
  584. dispatcher.convertMarker( 'removeMarker', 'name', gyRange );
  585. expect( dispatcher.fire.called ).to.be.false;
  586. } );
  587. it( 'should not convert marker if it is not in model root', () => {
  588. const element = new ModelElement( 'element', null, new ModelText( 'foo' ) );
  589. const eleRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 2 );
  590. sinon.spy( dispatcher, 'fire' );
  591. dispatcher.convertMarker( 'addMarker', 'name', eleRange );
  592. expect( dispatcher.fire.called ).to.be.false;
  593. dispatcher.convertMarker( 'removeMarker', 'name', eleRange );
  594. expect( dispatcher.fire.called ).to.be.false;
  595. } );
  596. it( 'should prepare consumable values', () => {
  597. dispatcher.on( 'addMarker:name', ( evt, data, consumable ) => {
  598. expect( consumable.test( data.item, 'addMarker:name' ) ).to.be.true;
  599. } );
  600. dispatcher.on( 'removeMarker:name', ( evt, data, consumable ) => {
  601. expect( consumable.test( data.item, 'removeMarker:name' ) ).to.be.true;
  602. } );
  603. dispatcher.convertMarker( 'addMarker', 'name', range );
  604. dispatcher.convertMarker( 'removeMarker', 'name', range );
  605. } );
  606. it( 'should fire conversion for each item in the range', () => {
  607. const element = new ModelElement( 'paragraph', null, [ new ModelText( 'foo bar baz' ) ] );
  608. root.appendChildren( [ element ] );
  609. range = ModelRange.createIn( root );
  610. const addMarkerData = [];
  611. const removeMarkerData = [];
  612. dispatcher.on( 'addMarker:name', ( evt, data ) => addMarkerData.push( data ) );
  613. dispatcher.on( 'removeMarker:name', ( evt, data ) => removeMarkerData.push( data ) );
  614. dispatcher.convertMarker( 'addMarker', 'name', range );
  615. dispatcher.convertMarker( 'removeMarker', 'name', range );
  616. // Check if events for all elements were fired.
  617. let i = 0;
  618. for ( const val of range ) {
  619. const nodeInRange = val.item;
  620. const addData = addMarkerData[ i ];
  621. const removeData = removeMarkerData[ i ];
  622. expect( addData.markerName ).to.equal( 'name' );
  623. expect( addData.markerRange ).to.equal( range );
  624. expect( addData.range.isEqual( ModelRange.createOn( nodeInRange ) ) );
  625. expect( removeData.markerName ).to.equal( 'name' );
  626. expect( removeData.markerRange ).to.equal( range );
  627. expect( removeData.range.isEqual( ModelRange.createOn( nodeInRange ) ) );
  628. if ( nodeInRange.is( 'textProxy' ) ) {
  629. expect( nodeInRange.data ).to.equal( addData.item.data );
  630. expect( nodeInRange.data ).to.equal( removeData.item.data );
  631. } else {
  632. expect( nodeInRange ).to.equal( addData.item );
  633. expect( nodeInRange ).to.equal( removeData.item );
  634. }
  635. i++;
  636. }
  637. } );
  638. it( 'should not fire events for already consumed items', () => {
  639. const element = new ModelElement( 'paragraph', null, [ new ModelText( 'foo bar baz' ) ] );
  640. root.appendChildren( [ element ] );
  641. const range = ModelRange.createIn( root );
  642. const addMarkerSpy = sinon.spy( ( evt, data, consumable ) => {
  643. // Consume all items in marker range.
  644. for ( const value of data.markerRange ) {
  645. consumable.consume( value.item, 'addMarker:marker' );
  646. }
  647. } );
  648. const removeMarkerSpy = sinon.spy( ( evt, data, consumable ) => {
  649. // Consume all items in marker range.
  650. for ( const value of data.markerRange ) {
  651. consumable.consume( value.item, 'removeMarker:marker' );
  652. }
  653. } );
  654. dispatcher.on( 'addMarker:marker', addMarkerSpy );
  655. dispatcher.on( 'addMarker:marker', removeMarkerSpy );
  656. dispatcher.convertMarker( 'addMarker', 'marker', range );
  657. dispatcher.convertMarker( 'removeMarker', 'marker', range );
  658. sinon.assert.calledOnce( addMarkerSpy );
  659. sinon.assert.calledOnce( removeMarkerSpy );
  660. } );
  661. it( 'should fire event for collapsed marker', () => {
  662. const range = ModelRange.createFromParentsAndOffsets( root, 1, root, 1 );
  663. const addMarkerSpy = sinon.spy( ( evt, data, consumable ) => {
  664. expect( data.markerRange ).to.equal( range );
  665. expect( data.markerName ).to.equal( 'marker' );
  666. expect( consumable.test( data.markerRange, evt.name ) ).to.be.true;
  667. } );
  668. const removeMarkerSpy = sinon.spy( ( evt, data, consumable ) => {
  669. expect( data.markerRange ).to.equal( range );
  670. expect( data.markerName ).to.equal( 'marker' );
  671. expect( consumable.test( data.markerRange, evt.name ) ).to.be.true;
  672. } );
  673. dispatcher.on( 'addMarker:marker', addMarkerSpy );
  674. dispatcher.on( 'addMarker:marker', removeMarkerSpy );
  675. dispatcher.convertMarker( 'addMarker', 'marker', range );
  676. dispatcher.convertMarker( 'removeMarker', 'marker', range );
  677. sinon.assert.calledOnce( addMarkerSpy );
  678. sinon.assert.calledOnce( removeMarkerSpy );
  679. } );
  680. } );
  681. } );