model-selection-to-view-converters.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. /**
  2. * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /* bender-tags: treecontroller */
  6. 'use strict';
  7. import ModelDocument from '/ckeditor5/engine/treemodel/document.js';
  8. import ModelElement from '/ckeditor5/engine/treemodel/element.js';
  9. import ModelRange from '/ckeditor5/engine/treemodel/range.js';
  10. import ModelPosition from '/ckeditor5/engine/treemodel/position.js';
  11. import ViewContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
  12. import ViewAttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
  13. import ViewWriter from '/ckeditor5/engine/treeview/writer.js';
  14. import ViewSelection from '/ckeditor5/engine/treeview/selection.js';
  15. import Mapper from '/ckeditor5/engine/treecontroller/mapper.js';
  16. import ModelConversionDispatcher from '/ckeditor5/engine/treecontroller/modelconversiondispatcher.js';
  17. import {
  18. convertRangeSelection,
  19. convertCollapsedSelection,
  20. convertSelectionAttribute,
  21. clearAttributes
  22. } from '/ckeditor5/engine/treecontroller/model-selection-to-view-converters.js';
  23. import {
  24. insertElement,
  25. insertText,
  26. wrap
  27. } from '/ckeditor5/engine/treecontroller/model-to-view-converters.js';
  28. let dispatcher, modelDoc, modelRoot, modelSelection, mapper, viewRoot, writer, viewSelection;
  29. beforeEach( () => {
  30. modelDoc = new ModelDocument();
  31. modelRoot = modelDoc.createRoot( 'main' );
  32. modelSelection = modelDoc.selection;
  33. viewRoot = new ViewContainerElement( 'viewRoot' );
  34. mapper = new Mapper();
  35. mapper.bindElements( modelRoot, viewRoot );
  36. writer = new ViewWriter();
  37. viewSelection = new ViewSelection();
  38. dispatcher = new ModelConversionDispatcher( { mapper, writer, viewSelection } );
  39. dispatcher.on( 'insert:$text', insertText() );
  40. dispatcher.on( 'addAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
  41. // Default selection converters.
  42. dispatcher.on( 'selection', clearAttributes() );
  43. dispatcher.on( 'selection', convertRangeSelection() );
  44. dispatcher.on( 'selection', convertCollapsedSelection() );
  45. } );
  46. describe( 'default converters', () => {
  47. beforeEach( () => {
  48. // Selection converters for selection attributes.
  49. dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'strong' ) ) );
  50. dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
  51. } );
  52. describe( 'range selection', () => {
  53. it( 'in same container', () => {
  54. test(
  55. [ 1, 4 ],
  56. 'foobar',
  57. 'f{oob}ar'
  58. );
  59. } );
  60. it( 'in same container, over attribute', () => {
  61. test(
  62. [ 1, 5 ],
  63. 'fo<$text bold=true>ob</$text>ar',
  64. 'f{o<strong>ob</strong>a}r'
  65. );
  66. } );
  67. it( 'in same container, next to attribute', () => {
  68. test(
  69. [ 1, 2 ],
  70. 'fo<$text bold=true>ob</$text>ar',
  71. 'f{o}<strong>ob</strong>ar'
  72. );
  73. } );
  74. it( 'in same attribute', () => {
  75. test(
  76. [ 2, 4 ],
  77. 'f<$text bold=true>ooba</$text>r',
  78. 'f<strong>o{ob}a</strong>r'
  79. );
  80. } );
  81. it( 'in same attribute, selection same as attribute', () => {
  82. test(
  83. [ 2, 4 ],
  84. 'fo<$text bold=true>ob</$text>ar',
  85. 'fo{<strong>ob</strong>}ar'
  86. );
  87. } );
  88. it( 'starts in text node, ends in attribute #1', () => {
  89. test(
  90. [ 1, 3 ],
  91. 'fo<$text bold=true>ob</$text>ar',
  92. 'f{o<strong>o}b</strong>ar'
  93. );
  94. } );
  95. it( 'starts in text node, ends in attribute #2', () => {
  96. test(
  97. [ 1, 4 ],
  98. 'fo<$text bold=true>ob</$text>ar',
  99. 'f{o<strong>ob</strong>}ar'
  100. );
  101. } );
  102. it( 'starts in attribute, ends in text node', () => {
  103. test(
  104. [ 3, 5 ],
  105. 'fo<$text bold=true>ob</$text>ar',
  106. 'fo<strong>o{b</strong>a}r'
  107. );
  108. } );
  109. it( 'consumes consumable values properly', () => {
  110. // Add callback that will fire before default ones.
  111. // This should prevent default callback doing anything.
  112. dispatcher.on( 'selection', ( evt, data, consumable ) => {
  113. expect( consumable.consume( data.selection, 'selection' ) ).to.be.true;
  114. }, null, 0 );
  115. // Similar test case as the first in this suite.
  116. test(
  117. [ 1, 4 ],
  118. 'foobar',
  119. 'foobar' // No selection in view.
  120. );
  121. } );
  122. } );
  123. describe( 'collapsed selection', () => {
  124. it( 'in container', () => {
  125. test(
  126. [ 1, 1 ],
  127. 'foobar',
  128. 'f{}oobar'
  129. );
  130. } );
  131. it( 'in attribute', () => {
  132. test(
  133. [ 3, 3 ],
  134. 'f<$text bold=true>ooba</$text>r',
  135. 'f<strong>oo{}ba</strong>r'
  136. );
  137. } );
  138. it( 'in container with extra attributes', () => {
  139. test(
  140. [ 1, 1 ],
  141. 'foobar',
  142. 'f<em>[]</em>oobar',
  143. { italic: true }
  144. );
  145. } );
  146. it( 'in attribute with extra attributes', () => {
  147. test(
  148. [ 3, 3 ],
  149. 'f<$text bold=true>ooba</$text>r',
  150. 'f<strong>oo</strong><em><strong>[]</strong></em><strong>ba</strong>r',
  151. { italic: true }
  152. );
  153. } );
  154. it( 'consumes consumable values properly', () => {
  155. // Add callbacks that will fire before default ones.
  156. // This should prevent default callbacks doing anything.
  157. dispatcher.on( 'selection', ( evt, data, consumable ) => {
  158. expect( consumable.consume( data.selection, 'selection' ) ).to.be.true;
  159. }, null, 0 );
  160. dispatcher.on( 'selectionAttribute:bold', ( evt, data, consumable ) => {
  161. expect( consumable.consume( data.selection, 'selectionAttribute:bold' ) ).to.be.true;
  162. }, null, 0 );
  163. // Similar test case as above
  164. test(
  165. [ 3, 3 ],
  166. 'f<$text bold=true>ooba</$text>r',
  167. 'f<strong>ooba</strong>r' // No selection in view.
  168. );
  169. } );
  170. } );
  171. } );
  172. describe( 'clean-up', () => {
  173. describe( 'convertRangeSelection', () => {
  174. it( 'should remove all ranges before adding new range', () => {
  175. test(
  176. [ 0, 2 ],
  177. 'foobar',
  178. '{fo}obar'
  179. );
  180. test(
  181. [ 3, 5 ],
  182. 'foobar',
  183. 'foo{ba}r'
  184. );
  185. expect( viewSelection.rangeCount ).to.equal( 1 );
  186. } );
  187. } );
  188. describe( 'convertCollapsedSelection', () => {
  189. it( 'should remove all ranges before adding new range', () => {
  190. test(
  191. [ 2, 2 ],
  192. 'foobar',
  193. 'fo{}obar'
  194. );
  195. test(
  196. [ 3, 3 ],
  197. 'foobar',
  198. 'foo{}bar'
  199. );
  200. expect( viewSelection.rangeCount ).to.equal( 1 );
  201. } );
  202. } );
  203. describe( 'clearAttributes', () => {
  204. it( 'should remove all ranges before adding new range', () => {
  205. dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
  206. dispatcher.on( 'addAttribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
  207. test(
  208. [ 3, 3 ],
  209. 'foobar',
  210. 'foo<b>[]</b>bar',
  211. { bold: 'true' }
  212. );
  213. const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
  214. modelDoc.selection.setRanges( [ modelRange ] );
  215. dispatcher.convertSelection( modelDoc.selection );
  216. expect( viewSelection.rangeCount ).to.equal( 1 );
  217. const viewString = bender.view.stringify( viewRoot, viewSelection, { showType: false } );
  218. expect( viewString ).to.equal( '<viewRoot>f{}oobar</viewRoot>' );
  219. } );
  220. } );
  221. } );
  222. describe( 'using element creator for attributes conversion', () => {
  223. beforeEach( () => {
  224. function styleElementCreator( styleValue ) {
  225. if ( styleValue == 'important' ) {
  226. return new ViewAttributeElement( 'strong', { style: 'text-transform:uppercase;' } );
  227. } else if ( styleValue == 'gold' ) {
  228. return new ViewAttributeElement( 'span', { style: 'color:yellow;' } );
  229. }
  230. }
  231. dispatcher.on( 'selectionAttribute:style', convertSelectionAttribute( styleElementCreator ) );
  232. dispatcher.on( 'addAttribute:style', wrap( styleElementCreator ) );
  233. dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
  234. } );
  235. describe( 'range selection', () => {
  236. it( 'in same container, over attribute', () => {
  237. test(
  238. [ 1, 5 ],
  239. 'fo<$text style="gold">ob</$text>ar',
  240. 'f{o<span style="color:yellow;">ob</span>a}r'
  241. );
  242. } );
  243. it( 'in same attribute', () => {
  244. test(
  245. [ 2, 4 ],
  246. 'f<$text style="gold">ooba</$text>r',
  247. 'f<span style="color:yellow;">o{ob}a</span>r'
  248. );
  249. } );
  250. it( 'in same attribute, selection same as attribute', () => {
  251. test(
  252. [ 2, 4 ],
  253. 'fo<$text style="important">ob</$text>ar',
  254. 'fo{<strong style="text-transform:uppercase;">ob</strong>}ar'
  255. );
  256. } );
  257. it( 'starts in attribute, ends in text node', () => {
  258. test(
  259. [ 3, 5 ],
  260. 'fo<$text style="important">ob</$text>ar',
  261. 'fo<strong style="text-transform:uppercase;">o{b</strong>a}r'
  262. );
  263. } );
  264. } );
  265. describe( 'collapsed selection', () => {
  266. it( 'in attribute', () => {
  267. test(
  268. [ 3, 3 ],
  269. 'f<$text style="gold">ooba</$text>r',
  270. 'f<span style="color:yellow;">oo{}ba</span>r'
  271. );
  272. } );
  273. it( 'in container with style attribute', () => {
  274. test(
  275. [ 1, 1 ],
  276. 'foobar',
  277. 'f<strong style="text-transform:uppercase;">[]</strong>oobar',
  278. { style: 'important' }
  279. );
  280. } );
  281. it( 'in style attribute with extra attributes #1', () => {
  282. test(
  283. [ 3, 3 ],
  284. 'f<$text style="gold">ooba</$text>r',
  285. 'f<span style="color:yellow;">oo</span>' +
  286. '<em><span style="color:yellow;">[]</span></em>' +
  287. '<span style="color:yellow;">ba</span>r',
  288. { italic: true }
  289. );
  290. } );
  291. it( 'in style attribute with extra attributes #2', () => {
  292. // In contrary to test above, we don't have strong + span on the selection.
  293. // This is because strong and span are both created by the same attribute.
  294. // Since style="important" overwrites style="gold" on selection, we have only strong element.
  295. // In example above, selection has both style and italic attribute.
  296. test(
  297. [ 3, 3 ],
  298. 'f<$text style="gold">ooba</$text>r',
  299. 'f<span style="color:yellow;">oo</span>' +
  300. '<strong style="text-transform:uppercase;">[]</strong>' +
  301. '<span style="color:yellow;">ba</span>r',
  302. { style: 'important' }
  303. );
  304. } );
  305. } );
  306. } );
  307. describe( 'table cell selection converter', () => {
  308. beforeEach( () => {
  309. // "Universal" converter to convert table structure.
  310. const tableConverter = insertElement( ( data ) => new ViewContainerElement( data.item.name ) );
  311. dispatcher.on( 'insert:table', tableConverter );
  312. dispatcher.on( 'insert:tr', tableConverter );
  313. dispatcher.on( 'insert:td', tableConverter );
  314. // Special converter for table cells.
  315. dispatcher.on( 'selection', ( evt, data, consumable, conversionApi ) => {
  316. const selection = data.selection;
  317. if ( !consumable.test( selection, 'selection' ) || selection.isCollapsed ) {
  318. return;
  319. }
  320. for ( let range of selection.getRanges() ) {
  321. const node = range.start.nodeAfter;
  322. if ( node == range.end.nodeBefore && node instanceof ModelElement && node.name == 'td' ) {
  323. consumable.consume( selection, 'selection' );
  324. let viewNode = conversionApi.mapper.toViewElement( node );
  325. viewNode.addClass( 'selected' );
  326. }
  327. }
  328. }, null, 0 );
  329. } );
  330. it( 'should not be used to convert selection that is not on table cell', () => {
  331. test(
  332. [ 1, 5 ],
  333. 'f<selection>o<$text bold=true>ob</$text>a</selection>r',
  334. 'f{o<strong>ob</strong>a}r'
  335. );
  336. } );
  337. it( 'should add a class to the selected table cell', () => {
  338. test(
  339. // table tr#0, table tr#1
  340. [ [ 0, 0, 0 ], [ 0, 0, 1 ] ],
  341. '<table><tr><td>foo</td></tr><tr><td>bar</td></tr></table>',
  342. '<table><tr><td class="selected">foo</td></tr><tr><td>bar</td></tr></table>'
  343. );
  344. } );
  345. it( 'should not be used if selection contains more than just a table cell', () => {
  346. test(
  347. // table tr td#1, table tr#2
  348. [ [ 0, 0, 0, 1 ], [ 0, 0, 2 ] ],
  349. '<table><tr><td>foo</td><td>bar</td></tr></table>',
  350. '<table><tr><td>f{oo</td><td>bar</td>]</tr></table>'
  351. );
  352. } );
  353. } );
  354. // Tests if the selection got correctly converted.
  355. // Because `setData` might use selection converters itself to set the selection, we can't use it
  356. // to set the selection (because then we would test converters using converters).
  357. // Instead, the `test` function expects to be passed `selectionPaths` which is an array containing two numbers or two arrays,
  358. // that are offsets or paths of selection positions in root element.
  359. function test( selectionPaths, modelInput, expectedView, selectionAttributes = {} ) {
  360. // Parse passed `modelInput` string and set it as current model.
  361. bender.model.setData( modelDoc, modelInput );
  362. // Manually set selection ranges using passed `selectionPaths`.
  363. let startPath = typeof selectionPaths[ 0 ] == 'number' ? [ selectionPaths[ 0 ] ] : selectionPaths[ 0 ];
  364. let endPath = typeof selectionPaths[ 1 ] == 'number' ? [ selectionPaths[ 1 ] ] : selectionPaths[ 1 ];
  365. let startPos = new ModelPosition( modelRoot, startPath );
  366. let endPos = new ModelPosition( modelRoot, endPath );
  367. modelSelection.setRanges( [ new ModelRange( startPos, endPos ) ] );
  368. // Updated selection attributes according to model.
  369. modelSelection._updateAttributes();
  370. // And add or remove passed attributes.
  371. for ( let key in selectionAttributes ) {
  372. let value = selectionAttributes[ key ];
  373. if ( value ) {
  374. modelSelection.setAttribute( key, value );
  375. } else {
  376. modelSelection.removeAttribute( key );
  377. }
  378. }
  379. // Remove view children since we do not want to convert deletion.
  380. viewRoot.removeChildren( 0, viewRoot.getChildCount() );
  381. // Convert model to view.
  382. dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
  383. dispatcher.convertSelection( modelSelection );
  384. // Stringify view and check if it is same as expected.
  385. expect( bender.view.stringify( viewRoot, viewSelection, { showType: false } ) ).to.equal( '<viewRoot>' + expectedView + '</viewRoot>' );
  386. }