8
0

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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  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. it( 'should convert backward selection', () => {
  123. test(
  124. [ 1, 3, 'backward' ],
  125. 'foobar',
  126. 'f{oo}bar'
  127. );
  128. expect( viewSelection.focus.offset ).to.equal( 1 );
  129. } );
  130. } );
  131. describe( 'collapsed selection', () => {
  132. it( 'in container', () => {
  133. test(
  134. [ 1, 1 ],
  135. 'foobar',
  136. 'f{}oobar'
  137. );
  138. } );
  139. it( 'in attribute', () => {
  140. test(
  141. [ 3, 3 ],
  142. 'f<$text bold=true>ooba</$text>r',
  143. 'f<strong>oo{}ba</strong>r'
  144. );
  145. } );
  146. it( 'in container with extra attributes', () => {
  147. test(
  148. [ 1, 1 ],
  149. 'foobar',
  150. 'f<em>[]</em>oobar',
  151. { italic: true }
  152. );
  153. } );
  154. it( 'in attribute with extra attributes', () => {
  155. test(
  156. [ 3, 3 ],
  157. 'f<$text bold=true>ooba</$text>r',
  158. 'f<strong>oo</strong><em><strong>[]</strong></em><strong>ba</strong>r',
  159. { italic: true }
  160. );
  161. } );
  162. it( 'consumes consumable values properly', () => {
  163. // Add callbacks that will fire before default ones.
  164. // This should prevent default callbacks doing anything.
  165. dispatcher.on( 'selection', ( evt, data, consumable ) => {
  166. expect( consumable.consume( data.selection, 'selection' ) ).to.be.true;
  167. }, null, 0 );
  168. dispatcher.on( 'selectionAttribute:bold', ( evt, data, consumable ) => {
  169. expect( consumable.consume( data.selection, 'selectionAttribute:bold' ) ).to.be.true;
  170. }, null, 0 );
  171. // Similar test case as above
  172. test(
  173. [ 3, 3 ],
  174. 'f<$text bold=true>ooba</$text>r',
  175. 'f<strong>ooba</strong>r' // No selection in view.
  176. );
  177. } );
  178. } );
  179. } );
  180. describe( 'clean-up', () => {
  181. describe( 'convertRangeSelection', () => {
  182. it( 'should remove all ranges before adding new range', () => {
  183. test(
  184. [ 0, 2 ],
  185. 'foobar',
  186. '{fo}obar'
  187. );
  188. test(
  189. [ 3, 5 ],
  190. 'foobar',
  191. 'foo{ba}r'
  192. );
  193. expect( viewSelection.rangeCount ).to.equal( 1 );
  194. } );
  195. } );
  196. describe( 'convertCollapsedSelection', () => {
  197. it( 'should remove all ranges before adding new range', () => {
  198. test(
  199. [ 2, 2 ],
  200. 'foobar',
  201. 'fo{}obar'
  202. );
  203. test(
  204. [ 3, 3 ],
  205. 'foobar',
  206. 'foo{}bar'
  207. );
  208. expect( viewSelection.rangeCount ).to.equal( 1 );
  209. } );
  210. } );
  211. describe( 'clearAttributes', () => {
  212. it( 'should remove all ranges before adding new range', () => {
  213. dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
  214. dispatcher.on( 'addAttribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
  215. test(
  216. [ 3, 3 ],
  217. 'foobar',
  218. 'foo<b>[]</b>bar',
  219. { bold: 'true' }
  220. );
  221. const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
  222. modelDoc.selection.setRanges( [ modelRange ] );
  223. dispatcher.convertSelection( modelDoc.selection );
  224. expect( viewSelection.rangeCount ).to.equal( 1 );
  225. const viewString = bender.view.stringify( viewRoot, viewSelection, { showType: false } );
  226. expect( viewString ).to.equal( '<viewRoot>f{}oobar</viewRoot>' );
  227. } );
  228. } );
  229. } );
  230. describe( 'using element creator for attributes conversion', () => {
  231. beforeEach( () => {
  232. function styleElementCreator( styleValue ) {
  233. if ( styleValue == 'important' ) {
  234. return new ViewAttributeElement( 'strong', { style: 'text-transform:uppercase;' } );
  235. } else if ( styleValue == 'gold' ) {
  236. return new ViewAttributeElement( 'span', { style: 'color:yellow;' } );
  237. }
  238. }
  239. dispatcher.on( 'selectionAttribute:style', convertSelectionAttribute( styleElementCreator ) );
  240. dispatcher.on( 'addAttribute:style', wrap( styleElementCreator ) );
  241. dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
  242. } );
  243. describe( 'range selection', () => {
  244. it( 'in same container, over attribute', () => {
  245. test(
  246. [ 1, 5 ],
  247. 'fo<$text style="gold">ob</$text>ar',
  248. 'f{o<span style="color:yellow;">ob</span>a}r'
  249. );
  250. } );
  251. it( 'in same attribute', () => {
  252. test(
  253. [ 2, 4 ],
  254. 'f<$text style="gold">ooba</$text>r',
  255. 'f<span style="color:yellow;">o{ob}a</span>r'
  256. );
  257. } );
  258. it( 'in same attribute, selection same as attribute', () => {
  259. test(
  260. [ 2, 4 ],
  261. 'fo<$text style="important">ob</$text>ar',
  262. 'fo{<strong style="text-transform:uppercase;">ob</strong>}ar'
  263. );
  264. } );
  265. it( 'starts in attribute, ends in text node', () => {
  266. test(
  267. [ 3, 5 ],
  268. 'fo<$text style="important">ob</$text>ar',
  269. 'fo<strong style="text-transform:uppercase;">o{b</strong>a}r'
  270. );
  271. } );
  272. } );
  273. describe( 'collapsed selection', () => {
  274. it( 'in attribute', () => {
  275. test(
  276. [ 3, 3 ],
  277. 'f<$text style="gold">ooba</$text>r',
  278. 'f<span style="color:yellow;">oo{}ba</span>r'
  279. );
  280. } );
  281. it( 'in container with style attribute', () => {
  282. test(
  283. [ 1, 1 ],
  284. 'foobar',
  285. 'f<strong style="text-transform:uppercase;">[]</strong>oobar',
  286. { style: 'important' }
  287. );
  288. } );
  289. it( 'in style attribute with extra attributes #1', () => {
  290. test(
  291. [ 3, 3 ],
  292. 'f<$text style="gold">ooba</$text>r',
  293. 'f<span style="color:yellow;">oo</span>' +
  294. '<em><span style="color:yellow;">[]</span></em>' +
  295. '<span style="color:yellow;">ba</span>r',
  296. { italic: true }
  297. );
  298. } );
  299. it( 'in style attribute with extra attributes #2', () => {
  300. // In contrary to test above, we don't have strong + span on the selection.
  301. // This is because strong and span are both created by the same attribute.
  302. // Since style="important" overwrites style="gold" on selection, we have only strong element.
  303. // In example above, selection has both style and italic attribute.
  304. test(
  305. [ 3, 3 ],
  306. 'f<$text style="gold">ooba</$text>r',
  307. 'f<span style="color:yellow;">oo</span>' +
  308. '<strong style="text-transform:uppercase;">[]</strong>' +
  309. '<span style="color:yellow;">ba</span>r',
  310. { style: 'important' }
  311. );
  312. } );
  313. } );
  314. } );
  315. describe( 'table cell selection converter', () => {
  316. beforeEach( () => {
  317. // "Universal" converter to convert table structure.
  318. const tableConverter = insertElement( ( data ) => new ViewContainerElement( data.item.name ) );
  319. dispatcher.on( 'insert:table', tableConverter );
  320. dispatcher.on( 'insert:tr', tableConverter );
  321. dispatcher.on( 'insert:td', tableConverter );
  322. // Special converter for table cells.
  323. dispatcher.on( 'selection', ( evt, data, consumable, conversionApi ) => {
  324. const selection = data.selection;
  325. if ( !consumable.test( selection, 'selection' ) || selection.isCollapsed ) {
  326. return;
  327. }
  328. for ( let range of selection.getRanges() ) {
  329. const node = range.start.nodeAfter;
  330. if ( node == range.end.nodeBefore && node instanceof ModelElement && node.name == 'td' ) {
  331. consumable.consume( selection, 'selection' );
  332. let viewNode = conversionApi.mapper.toViewElement( node );
  333. viewNode.addClass( 'selected' );
  334. }
  335. }
  336. }, null, 0 );
  337. } );
  338. it( 'should not be used to convert selection that is not on table cell', () => {
  339. test(
  340. [ 1, 5 ],
  341. 'f<selection>o<$text bold=true>ob</$text>a</selection>r',
  342. 'f{o<strong>ob</strong>a}r'
  343. );
  344. } );
  345. it( 'should add a class to the selected table cell', () => {
  346. test(
  347. // table tr#0, table tr#1
  348. [ [ 0, 0, 0 ], [ 0, 0, 1 ] ],
  349. '<table><tr><td>foo</td></tr><tr><td>bar</td></tr></table>',
  350. '<table><tr><td class="selected">foo</td></tr><tr><td>bar</td></tr></table>'
  351. );
  352. } );
  353. it( 'should not be used if selection contains more than just a table cell', () => {
  354. test(
  355. // table tr td#1, table tr#2
  356. [ [ 0, 0, 0, 1 ], [ 0, 0, 2 ] ],
  357. '<table><tr><td>foo</td><td>bar</td></tr></table>',
  358. '<table><tr><td>f{oo</td><td>bar</td>]</tr></table>'
  359. );
  360. } );
  361. } );
  362. // Tests if the selection got correctly converted.
  363. // Because `setData` might use selection converters itself to set the selection, we can't use it
  364. // to set the selection (because then we would test converters using converters).
  365. // Instead, the `test` function expects to be passed `selectionPaths` which is an array containing two numbers or two arrays,
  366. // that are offsets or paths of selection positions in root element.
  367. function test( selectionPaths, modelInput, expectedView, selectionAttributes = {} ) {
  368. // Parse passed `modelInput` string and set it as current model.
  369. bender.model.setData( modelDoc, modelInput );
  370. // Manually set selection ranges using passed `selectionPaths`.
  371. const startPath = typeof selectionPaths[ 0 ] == 'number' ? [ selectionPaths[ 0 ] ] : selectionPaths[ 0 ];
  372. const endPath = typeof selectionPaths[ 1 ] == 'number' ? [ selectionPaths[ 1 ] ] : selectionPaths[ 1 ];
  373. const startPos = new ModelPosition( modelRoot, startPath );
  374. const endPos = new ModelPosition( modelRoot, endPath );
  375. const isBackward = selectionPaths[ 2 ] === 'backward';
  376. modelSelection.setRanges( [ new ModelRange( startPos, endPos ) ], isBackward );
  377. // Updated selection attributes according to model.
  378. modelSelection._updateAttributes();
  379. // And add or remove passed attributes.
  380. for ( let key in selectionAttributes ) {
  381. let value = selectionAttributes[ key ];
  382. if ( value ) {
  383. modelSelection.setAttribute( key, value );
  384. } else {
  385. modelSelection.removeAttribute( key );
  386. }
  387. }
  388. // Remove view children since we do not want to convert deletion.
  389. viewRoot.removeChildren( 0, viewRoot.getChildCount() );
  390. // Convert model to view.
  391. dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
  392. dispatcher.convertSelection( modelSelection );
  393. // Stringify view and check if it is same as expected.
  394. expect( bender.view.stringify( viewRoot, viewSelection, { showType: false } ) ).to.equal( '<viewRoot>' + expectedView + '</viewRoot>' );
  395. }