conversion.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. /**
  2. * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. import Conversion from '../../src/conversion/conversion';
  6. import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  7. import UpcastDispatcher from '../../src/conversion/upcastdispatcher';
  8. import { convertText, convertToModelFragment } from '../../src/conversion/upcast-converters';
  9. import EditingController from '../../src/controller/editingcontroller';
  10. import Model from '../../src/model/model';
  11. import ModelRange from '../../src/model/range';
  12. import { stringify as viewStringify, parse as viewParse } from '../../src/dev-utils/view';
  13. import { stringify as modelStringify } from '../../src/dev-utils/model';
  14. describe( 'Conversion', () => {
  15. let conversion, dispA, dispB;
  16. beforeEach( () => {
  17. conversion = new Conversion();
  18. // Placeholders. Will be used only to see if their were given as attribute for a spy function.
  19. dispA = Symbol( 'dispA' );
  20. dispB = Symbol( 'dispB' );
  21. conversion.register( 'ab', [ dispA, dispB ] );
  22. conversion.register( 'a', [ dispA ] );
  23. conversion.register( 'b', [ dispB ] );
  24. } );
  25. describe( 'register()', () => {
  26. it( 'should throw when trying to use same group name twice', () => {
  27. expect( () => {
  28. conversion.register( 'ab' );
  29. } ).to.throw( CKEditorError, /conversion-register-group-exists/ );
  30. } );
  31. } );
  32. describe( 'for()', () => {
  33. it( 'should return object with .add() method', () => {
  34. const forResult = conversion.for( 'ab' );
  35. expect( forResult.add ).to.be.instanceof( Function );
  36. } );
  37. it( 'should throw if non-existing group name has been used', () => {
  38. expect( () => {
  39. conversion.for( 'foo' );
  40. } ).to.throw( CKEditorError, /conversion-for-unknown-group/ );
  41. } );
  42. } );
  43. describe( 'add()', () => {
  44. let helperA, helperB;
  45. beforeEach( () => {
  46. helperA = sinon.stub();
  47. helperB = sinon.stub();
  48. } );
  49. it( 'should be chainable', () => {
  50. const forResult = conversion.for( 'ab' );
  51. const addResult = forResult.add( () => {} );
  52. expect( addResult ).to.equal( addResult.add( () => {} ) );
  53. } );
  54. it( 'should fire given helper for every dispatcher in given group', () => {
  55. conversion.for( 'ab' ).add( helperA );
  56. expect( helperA.calledWithExactly( dispA ) ).to.be.true;
  57. expect( helperA.calledWithExactly( dispB ) ).to.be.true;
  58. conversion.for( 'b' ).add( helperB );
  59. expect( helperB.calledWithExactly( dispA ) ).to.be.false;
  60. expect( helperB.calledWithExactly( dispB ) ).to.be.true;
  61. } );
  62. } );
  63. describe( 'converters', () => {
  64. let viewDispatcher, model, schema, conversion, modelRoot, viewRoot;
  65. beforeEach( () => {
  66. model = new Model();
  67. const controller = new EditingController( model );
  68. const modelDoc = model.document;
  69. modelRoot = modelDoc.createRoot();
  70. viewRoot = controller.view.document.getRoot();
  71. // Set name of view root the same as dom root.
  72. // This is a mock of attaching view root to dom root.
  73. viewRoot._name = 'div';
  74. schema = model.schema;
  75. schema.extend( '$text', {
  76. allowAttributes: [ 'bold' ]
  77. } );
  78. schema.register( 'paragraph', {
  79. inheritAllFrom: '$block'
  80. } );
  81. viewDispatcher = new UpcastDispatcher( model, { schema } );
  82. viewDispatcher.on( 'text', convertText() );
  83. viewDispatcher.on( 'element', convertToModelFragment(), { priority: 'lowest' } );
  84. viewDispatcher.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
  85. conversion = new Conversion();
  86. conversion.register( 'upcast', [ viewDispatcher ] );
  87. conversion.register( 'downcast', [ controller.downcastDispatcher ] );
  88. } );
  89. describe( 'elementToElement', () => {
  90. it( 'config.view is a string', () => {
  91. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  92. test( '<p>Foo</p>', '<paragraph>Foo</paragraph>' );
  93. } );
  94. it( 'config.priority is defined', () => {
  95. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  96. conversion.elementToElement( { model: 'paragraph', view: 'div', priority: 'high' } );
  97. test( '<div>Foo</div>', '<paragraph>Foo</paragraph>' );
  98. test( '<p>Foo</p>', '<paragraph>Foo</paragraph>', '<div>Foo</div>' );
  99. } );
  100. it( 'config.view is an object', () => {
  101. schema.register( 'fancyParagraph', {
  102. inheritAllFrom: 'paragraph'
  103. } );
  104. conversion.elementToElement( {
  105. model: 'fancyParagraph',
  106. view: {
  107. name: 'p',
  108. class: 'fancy'
  109. }
  110. } );
  111. test( '<p class="fancy">Foo</p>', '<fancyParagraph>Foo</fancyParagraph>' );
  112. } );
  113. it( 'config.view is an object with upcastAlso defined', () => {
  114. conversion.elementToElement( {
  115. model: 'paragraph',
  116. view: 'p',
  117. upcastAlso: [
  118. 'div',
  119. {
  120. // Any element with `display: block` style.
  121. name: /./,
  122. style: {
  123. display: 'block'
  124. }
  125. }
  126. ]
  127. } );
  128. test( '<p>Foo</p>', '<paragraph>Foo</paragraph>' );
  129. test( '<div>Foo</div>', '<paragraph>Foo</paragraph>', '<p>Foo</p>' );
  130. test( '<span style="display:block">Foo</span>', '<paragraph>Foo</paragraph>', '<p>Foo</p>' );
  131. } );
  132. it( 'upcastAlso given as a function', () => {
  133. schema.register( 'heading', {
  134. inheritAllFrom: '$block'
  135. } );
  136. conversion.elementToElement( {
  137. model: 'heading',
  138. view: 'h2',
  139. upcastAlso: viewElement => {
  140. const fontSize = viewElement.getStyle( 'font-size' );
  141. if ( !fontSize ) {
  142. return null;
  143. }
  144. const match = fontSize.match( /(\d+)\s*px/ );
  145. if ( !match ) {
  146. return null;
  147. }
  148. const size = Number( match[ 1 ] );
  149. if ( size >= 26 ) {
  150. return { name: true, style: [ 'font-size' ] };
  151. }
  152. return null;
  153. }
  154. } );
  155. conversion.elementToElement( {
  156. model: 'paragraph',
  157. view: 'p'
  158. } );
  159. test( '<p></p>', '<paragraph></paragraph>' );
  160. test( '<p style="font-size:20px"></p>', '<paragraph></paragraph>', '<p></p>' );
  161. test( '<h2></h2>', '<heading></heading>' );
  162. test( '<p style="font-size:26px"></p>', '<heading></heading>', '<h2></h2>' );
  163. } );
  164. } );
  165. describe( 'attributeToElement', () => {
  166. beforeEach( () => {
  167. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  168. } );
  169. it( 'config.view is a string', () => {
  170. conversion.attributeToElement( { model: 'bold', view: 'strong' } );
  171. test( '<p><strong>Foo</strong> bar</p>', '<paragraph><$text bold="true">Foo</$text> bar</paragraph>' );
  172. } );
  173. it( 'config.priority is defined', () => {
  174. conversion.attributeToElement( { model: 'bold', view: 'strong' } );
  175. conversion.attributeToElement( { model: 'bold', view: 'b', priority: 'high' } );
  176. test( '<p><b>Foo</b></p>', '<paragraph><$text bold="true">Foo</$text></paragraph>' );
  177. test( '<p><strong>Foo</strong></p>', '<paragraph><$text bold="true">Foo</$text></paragraph>', '<p><b>Foo</b></p>' );
  178. } );
  179. it( 'config.view is an object', () => {
  180. conversion.attributeToElement( {
  181. model: 'bold',
  182. view: {
  183. name: 'span',
  184. class: 'bold'
  185. }
  186. } );
  187. test( '<p><span class="bold">Foo</span> bar</p>', '<paragraph><$text bold="true">Foo</$text> bar</paragraph>' );
  188. } );
  189. it( 'config.view is an object with upcastAlso defined', () => {
  190. conversion.attributeToElement( {
  191. model: 'bold',
  192. view: 'strong',
  193. upcastAlso: [
  194. 'b',
  195. {
  196. name: 'span',
  197. class: 'bold'
  198. },
  199. {
  200. name: 'span',
  201. style: {
  202. 'font-weight': 'bold'
  203. }
  204. },
  205. viewElement => {
  206. const fontWeight = viewElement.getStyle( 'font-weight' );
  207. if ( viewElement.is( 'span' ) && fontWeight && /\d+/.test( fontWeight ) && Number( fontWeight ) > 500 ) {
  208. return {
  209. name: true,
  210. style: [ 'font-weight' ]
  211. };
  212. }
  213. }
  214. ]
  215. } );
  216. test(
  217. '<p><strong>Foo</strong></p>',
  218. '<paragraph><$text bold="true">Foo</$text></paragraph>'
  219. );
  220. test(
  221. '<p><b>Foo</b></p>',
  222. '<paragraph><$text bold="true">Foo</$text></paragraph>',
  223. '<p><strong>Foo</strong></p>'
  224. );
  225. test(
  226. '<p><span class="bold">Foo</span></p>',
  227. '<paragraph><$text bold="true">Foo</$text></paragraph>',
  228. '<p><strong>Foo</strong></p>'
  229. );
  230. test(
  231. '<p><span style="font-weight: bold;">Foo</span></p>',
  232. '<paragraph><$text bold="true">Foo</$text></paragraph>',
  233. '<p><strong>Foo</strong></p>'
  234. );
  235. test(
  236. '<p><span style="font-weight: 500;">Foo</span></p>',
  237. '<paragraph>Foo</paragraph>',
  238. '<p>Foo</p>'
  239. );
  240. test(
  241. '<p><span style="font-weight: 600;">Foo</span></p>',
  242. '<paragraph><$text bold="true">Foo</$text></paragraph>',
  243. '<p><strong>Foo</strong></p>'
  244. );
  245. } );
  246. it( 'model attribute value is enumerable', () => {
  247. schema.extend( '$text', {
  248. allowAttributes: [ 'fontSize' ]
  249. } );
  250. conversion.attributeToElement( {
  251. model: {
  252. key: 'fontSize',
  253. values: [ 'big', 'small' ]
  254. },
  255. view: {
  256. big: {
  257. name: 'span',
  258. style: {
  259. 'font-size': '1.2em'
  260. }
  261. },
  262. small: {
  263. name: 'span',
  264. style: {
  265. 'font-size': '0.8em'
  266. }
  267. }
  268. },
  269. upcastAlso: {
  270. big: viewElement => {
  271. const fontSize = viewElement.getStyle( 'font-size' );
  272. if ( !fontSize ) {
  273. return null;
  274. }
  275. const match = fontSize.match( /(\d+)\s*px/ );
  276. if ( !match ) {
  277. return null;
  278. }
  279. const size = Number( match[ 1 ] );
  280. if ( viewElement.is( 'span' ) && size > 10 ) {
  281. return { name: true, style: [ 'font-size' ] };
  282. }
  283. return null;
  284. },
  285. small: viewElement => {
  286. const fontSize = viewElement.getStyle( 'font-size' );
  287. if ( !fontSize ) {
  288. return null;
  289. }
  290. const match = fontSize.match( /(\d+)\s*px/ );
  291. if ( !match ) {
  292. return null;
  293. }
  294. const size = Number( match[ 1 ] );
  295. if ( viewElement.is( 'span' ) && size < 10 ) {
  296. return { name: true, style: [ 'font-size' ] };
  297. }
  298. return null;
  299. }
  300. }
  301. } );
  302. test(
  303. '<p><span style="font-size:1.2em">Foo</span> bar</p>',
  304. '<paragraph><$text fontSize="big">Foo</$text> bar</paragraph>'
  305. );
  306. test(
  307. '<p><span style="font-size:12px">Foo</span> bar</p>',
  308. '<paragraph><$text fontSize="big">Foo</$text> bar</paragraph>',
  309. '<p><span style="font-size:1.2em">Foo</span> bar</p>'
  310. );
  311. test(
  312. '<p><span style="font-size:0.8em">Foo</span> bar</p>',
  313. '<paragraph><$text fontSize="small">Foo</$text> bar</paragraph>'
  314. );
  315. test(
  316. '<p><span style="font-size:8px">Foo</span> bar</p>',
  317. '<paragraph><$text fontSize="small">Foo</$text> bar</paragraph>',
  318. '<p><span style="font-size:0.8em">Foo</span> bar</p>'
  319. );
  320. test(
  321. '<p><span style="font-size:10px">Foo</span> bar</p>',
  322. '<paragraph>Foo bar</paragraph>',
  323. '<p>Foo bar</p>'
  324. );
  325. } );
  326. } );
  327. describe( 'attributeToAttribute', () => {
  328. beforeEach( () => {
  329. conversion.elementToElement( { model: 'image', view: 'img' } );
  330. schema.register( 'image', {
  331. inheritAllFrom: '$block',
  332. } );
  333. } );
  334. it( 'config.view and config.model are strings', () => {
  335. schema.extend( 'image', {
  336. allowAttributes: [ 'source' ]
  337. } );
  338. conversion.attributeToAttribute( { model: 'source', view: 'src' } );
  339. test( '<img src="foo.jpg"></img>', '<image source="foo.jpg"></image>' );
  340. } );
  341. it( 'config.view and config.model are objects', () => {
  342. schema.extend( 'image', {
  343. allowAttributes: [ 'aside' ]
  344. } );
  345. conversion.attributeToAttribute( {
  346. model: {
  347. name: 'image',
  348. key: 'aside',
  349. values: [ 'aside' ]
  350. },
  351. view: {
  352. aside: {
  353. name: 'img',
  354. key: 'class',
  355. value: [ 'aside', 'half-size' ]
  356. }
  357. }
  358. } );
  359. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  360. test( '<img class="aside half-size"></img>', '<image aside="aside"></image>' );
  361. test( '<p class="aside half-size"></p>', '<paragraph></paragraph>', '<p></p>' );
  362. } );
  363. it( 'config.view and config.model are objects - convert to style attribute', () => {
  364. schema.extend( 'image', {
  365. allowAttributes: [ 'aside' ]
  366. } );
  367. conversion.attributeToAttribute( {
  368. model: {
  369. name: 'image',
  370. key: 'aside',
  371. values: [ 'aside' ]
  372. },
  373. view: {
  374. aside: {
  375. name: 'img',
  376. key: 'style',
  377. value: {
  378. float: 'right',
  379. width: '50%',
  380. margin: '5px'
  381. }
  382. }
  383. }
  384. } );
  385. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  386. test( '<img style="float:right;margin:5px;width:50%"></img>', '<image aside="aside"></image>' );
  387. test( '<p style="float:right;margin:5px;width:50%"></p>', '<paragraph></paragraph>', '<p></p>' );
  388. } );
  389. it( 'config is an array with upcastAlso defined', () => {
  390. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  391. schema.extend( 'paragraph', {
  392. allowAttributes: [ 'align' ]
  393. } );
  394. conversion.attributeToAttribute( {
  395. model: {
  396. key: 'align',
  397. values: [ 'right', 'center' ]
  398. },
  399. view: {
  400. right: {
  401. key: 'class',
  402. value: 'align-right'
  403. },
  404. center: {
  405. key: 'class',
  406. value: 'align-center'
  407. }
  408. },
  409. upcastAlso: {
  410. right: {
  411. style: {
  412. 'text-align': 'right'
  413. }
  414. },
  415. center: {
  416. style: {
  417. 'text-align': 'center'
  418. }
  419. }
  420. }
  421. } );
  422. test(
  423. '<p class="align-right">Foo</p>',
  424. '<paragraph align="right">Foo</paragraph>'
  425. );
  426. test(
  427. '<p style="text-align:right">Foo</p>',
  428. '<paragraph align="right">Foo</paragraph>',
  429. '<p class="align-right">Foo</p>'
  430. );
  431. test(
  432. '<p class="align-center">Foo</p>',
  433. '<paragraph align="center">Foo</paragraph>'
  434. );
  435. test(
  436. '<p style="text-align:center">Foo</p>',
  437. '<paragraph align="center">Foo</paragraph>',
  438. '<p class="align-center">Foo</p>'
  439. );
  440. } );
  441. } );
  442. function test( input, expectedModel, expectedView = null ) {
  443. loadData( input );
  444. expect( modelStringify( model.document.getRoot() ) ).to.equal( expectedModel );
  445. expect( viewStringify( viewRoot, null, { ignoreRoot: true } ) ).to.equal( expectedView || input );
  446. }
  447. function loadData( input ) {
  448. const parsedView = viewParse( input );
  449. let convertedModel;
  450. model.change( writer => {
  451. convertedModel = viewDispatcher.convert( parsedView, writer );
  452. } );
  453. model.change( writer => {
  454. writer.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, modelRoot.maxOffset ) );
  455. writer.insert( convertedModel, modelRoot, 0 );
  456. } );
  457. }
  458. } );
  459. } );