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

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