conversion.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  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. classes: '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. styles: {
  122. display: 'block'
  123. }
  124. }
  125. ]
  126. } );
  127. test( '<p>Foo</p>', '<paragraph>Foo</paragraph>' );
  128. test( '<div>Foo</div>', '<paragraph>Foo</paragraph>', '<p>Foo</p>' );
  129. test( '<span style="display:block">Foo</span>', '<paragraph>Foo</paragraph>', '<p>Foo</p>' );
  130. } );
  131. it( 'upcastAlso given as a function', () => {
  132. schema.register( 'heading', {
  133. inheritAllFrom: '$block'
  134. } );
  135. conversion.elementToElement( {
  136. model: 'heading',
  137. view: 'h2',
  138. upcastAlso: viewElement => {
  139. const fontSize = viewElement.getStyle( 'font-size' );
  140. if ( !fontSize ) {
  141. return null;
  142. }
  143. const match = fontSize.match( /(\d+)\s*px/ );
  144. if ( !match ) {
  145. return null;
  146. }
  147. const size = Number( match[ 1 ] );
  148. if ( size >= 26 ) {
  149. return { name: true, style: [ 'font-size' ] };
  150. }
  151. return null;
  152. }
  153. } );
  154. conversion.elementToElement( {
  155. model: 'paragraph',
  156. view: 'p'
  157. } );
  158. test( '<p></p>', '<paragraph></paragraph>' );
  159. test( '<p style="font-size:20px"></p>', '<paragraph></paragraph>', '<p></p>' );
  160. test( '<h2></h2>', '<heading></heading>' );
  161. test( '<p style="font-size:26px"></p>', '<heading></heading>', '<h2></h2>' );
  162. } );
  163. } );
  164. describe( 'attributeToElement', () => {
  165. beforeEach( () => {
  166. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  167. } );
  168. it( 'config.view is a string', () => {
  169. conversion.attributeToElement( { model: 'bold', view: 'strong' } );
  170. test( '<p><strong>Foo</strong> bar</p>', '<paragraph><$text bold="true">Foo</$text> bar</paragraph>' );
  171. } );
  172. it( 'config.priority is defined', () => {
  173. conversion.attributeToElement( { model: 'bold', view: 'strong' } );
  174. conversion.attributeToElement( { model: 'bold', view: 'b', priority: 'high' } );
  175. test( '<p><b>Foo</b></p>', '<paragraph><$text bold="true">Foo</$text></paragraph>' );
  176. test( '<p><strong>Foo</strong></p>', '<paragraph><$text bold="true">Foo</$text></paragraph>', '<p><b>Foo</b></p>' );
  177. } );
  178. it( 'config.view is an object', () => {
  179. conversion.attributeToElement( {
  180. model: 'bold',
  181. view: {
  182. name: 'span',
  183. classes: 'bold'
  184. }
  185. } );
  186. test( '<p><span class="bold">Foo</span> bar</p>', '<paragraph><$text bold="true">Foo</$text> bar</paragraph>' );
  187. } );
  188. it( 'config.view is an object with upcastAlso defined', () => {
  189. conversion.attributeToElement( {
  190. model: 'bold',
  191. view: 'strong',
  192. upcastAlso: [
  193. 'b',
  194. {
  195. name: 'span',
  196. classes: 'bold'
  197. },
  198. {
  199. name: 'span',
  200. styles: {
  201. 'font-weight': 'bold'
  202. }
  203. },
  204. viewElement => {
  205. const fontWeight = viewElement.getStyle( 'font-weight' );
  206. if ( viewElement.is( 'span' ) && fontWeight && /\d+/.test( fontWeight ) && Number( fontWeight ) > 500 ) {
  207. return {
  208. name: true,
  209. styles: [ 'font-weight' ]
  210. };
  211. }
  212. }
  213. ]
  214. } );
  215. test(
  216. '<p><strong>Foo</strong></p>',
  217. '<paragraph><$text bold="true">Foo</$text></paragraph>'
  218. );
  219. test(
  220. '<p><b>Foo</b></p>',
  221. '<paragraph><$text bold="true">Foo</$text></paragraph>',
  222. '<p><strong>Foo</strong></p>'
  223. );
  224. test(
  225. '<p><span class="bold">Foo</span></p>',
  226. '<paragraph><$text bold="true">Foo</$text></paragraph>',
  227. '<p><strong>Foo</strong></p>'
  228. );
  229. test(
  230. '<p><span style="font-weight: bold;">Foo</span></p>',
  231. '<paragraph><$text bold="true">Foo</$text></paragraph>',
  232. '<p><strong>Foo</strong></p>'
  233. );
  234. test(
  235. '<p><span style="font-weight: 500;">Foo</span></p>',
  236. '<paragraph>Foo</paragraph>',
  237. '<p>Foo</p>'
  238. );
  239. test(
  240. '<p><span style="font-weight: 600;">Foo</span></p>',
  241. '<paragraph><$text bold="true">Foo</$text></paragraph>',
  242. '<p><strong>Foo</strong></p>'
  243. );
  244. } );
  245. it( 'model attribute value is enumerable', () => {
  246. schema.extend( '$text', {
  247. allowAttributes: [ 'fontSize' ]
  248. } );
  249. conversion.attributeToElement( {
  250. model: {
  251. key: 'fontSize',
  252. values: [ 'big', 'small' ]
  253. },
  254. view: {
  255. big: {
  256. name: 'span',
  257. styles: {
  258. 'font-size': '1.2em'
  259. }
  260. },
  261. small: {
  262. name: 'span',
  263. styles: {
  264. 'font-size': '0.8em'
  265. }
  266. }
  267. },
  268. upcastAlso: {
  269. big: viewElement => {
  270. const fontSize = viewElement.getStyle( 'font-size' );
  271. if ( !fontSize ) {
  272. return null;
  273. }
  274. const match = fontSize.match( /(\d+)\s*px/ );
  275. if ( !match ) {
  276. return null;
  277. }
  278. const size = Number( match[ 1 ] );
  279. if ( viewElement.is( 'span' ) && size > 10 ) {
  280. return { name: true, style: [ 'font-size' ] };
  281. }
  282. return null;
  283. },
  284. small: viewElement => {
  285. const fontSize = viewElement.getStyle( 'font-size' );
  286. if ( !fontSize ) {
  287. return null;
  288. }
  289. const match = fontSize.match( /(\d+)\s*px/ );
  290. if ( !match ) {
  291. return null;
  292. }
  293. const size = Number( match[ 1 ] );
  294. if ( viewElement.is( 'span' ) && size < 10 ) {
  295. return { name: true, style: [ 'font-size' ] };
  296. }
  297. return null;
  298. }
  299. }
  300. } );
  301. test(
  302. '<p><span style="font-size:1.2em">Foo</span> bar</p>',
  303. '<paragraph><$text fontSize="big">Foo</$text> bar</paragraph>'
  304. );
  305. test(
  306. '<p><span style="font-size:12px">Foo</span> bar</p>',
  307. '<paragraph><$text fontSize="big">Foo</$text> bar</paragraph>',
  308. '<p><span style="font-size:1.2em">Foo</span> bar</p>'
  309. );
  310. test(
  311. '<p><span style="font-size:0.8em">Foo</span> bar</p>',
  312. '<paragraph><$text fontSize="small">Foo</$text> bar</paragraph>'
  313. );
  314. test(
  315. '<p><span style="font-size:8px">Foo</span> bar</p>',
  316. '<paragraph><$text fontSize="small">Foo</$text> bar</paragraph>',
  317. '<p><span style="font-size:0.8em">Foo</span> bar</p>'
  318. );
  319. test(
  320. '<p><span style="font-size:10px">Foo</span> bar</p>',
  321. '<paragraph>Foo bar</paragraph>',
  322. '<p>Foo bar</p>'
  323. );
  324. } );
  325. it( 'config.model.name is given', () => {
  326. schema.extend( '$text', {
  327. allowAttributes: [ 'textDecoration' ]
  328. } );
  329. conversion.attributeToElement( {
  330. model: {
  331. key: 'textDecoration',
  332. values: [ 'underline', 'lineThrough' ],
  333. name: '$text'
  334. },
  335. view: {
  336. underline: {
  337. name: 'span',
  338. styles: {
  339. 'text-decoration': 'underline'
  340. }
  341. },
  342. lineThrough: {
  343. name: 'span',
  344. styles: {
  345. 'text-decoration': 'line-through'
  346. }
  347. }
  348. }
  349. } );
  350. test(
  351. '<p><span style="text-decoration:underline">Foo</span></p>',
  352. '<paragraph><$text textDecoration="underline">Foo</$text></paragraph>'
  353. );
  354. test(
  355. '<p><span style="text-decoration:line-through">Foo</span></p>',
  356. '<paragraph><$text textDecoration="lineThrough">Foo</$text></paragraph>'
  357. );
  358. test(
  359. '<p><span style="text-decoration:underline">Foo</span></p>',
  360. '<paragraph><$text textDecoration="underline">Foo</$text></paragraph>'
  361. );
  362. } );
  363. } );
  364. describe( 'attributeToAttribute', () => {
  365. beforeEach( () => {
  366. conversion.elementToElement( { model: 'image', view: 'img' } );
  367. schema.register( 'image', {
  368. inheritAllFrom: '$block',
  369. } );
  370. } );
  371. it( 'config.view and config.model are strings', () => {
  372. schema.extend( 'image', {
  373. allowAttributes: [ 'source' ]
  374. } );
  375. conversion.attributeToAttribute( { model: 'source', view: 'src' } );
  376. test( '<img src="foo.jpg"></img>', '<image source="foo.jpg"></image>' );
  377. } );
  378. it( 'config.view and config.model are objects', () => {
  379. schema.extend( 'image', {
  380. allowAttributes: [ 'aside' ]
  381. } );
  382. conversion.attributeToAttribute( {
  383. model: {
  384. name: 'image',
  385. key: 'aside',
  386. values: [ 'aside' ]
  387. },
  388. view: {
  389. aside: {
  390. name: 'img',
  391. key: 'class',
  392. value: [ 'aside', 'half-size' ]
  393. }
  394. }
  395. } );
  396. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  397. test( '<img class="aside half-size"></img>', '<image aside="aside"></image>' );
  398. test( '<p class="aside half-size"></p>', '<paragraph></paragraph>', '<p></p>' );
  399. } );
  400. it( 'config.view and config.model are objects - convert to style attribute', () => {
  401. schema.extend( 'image', {
  402. allowAttributes: [ 'aside' ]
  403. } );
  404. conversion.attributeToAttribute( {
  405. model: {
  406. name: 'image',
  407. key: 'aside',
  408. values: [ 'aside' ]
  409. },
  410. view: {
  411. aside: {
  412. name: 'img',
  413. key: 'style',
  414. value: {
  415. float: 'right',
  416. width: '50%',
  417. margin: '5px'
  418. }
  419. }
  420. }
  421. } );
  422. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  423. test( '<img style="float:right;margin:5px;width:50%"></img>', '<image aside="aside"></image>' );
  424. test( '<p style="float:right;margin:5px;width:50%"></p>', '<paragraph></paragraph>', '<p></p>' );
  425. } );
  426. it( 'config is an array with upcastAlso defined', () => {
  427. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  428. schema.extend( 'paragraph', {
  429. allowAttributes: [ 'align' ]
  430. } );
  431. conversion.attributeToAttribute( {
  432. model: {
  433. key: 'align',
  434. values: [ 'right', 'center' ]
  435. },
  436. view: {
  437. right: {
  438. key: 'class',
  439. value: 'align-right'
  440. },
  441. center: {
  442. key: 'class',
  443. value: 'align-center'
  444. }
  445. },
  446. upcastAlso: {
  447. right: {
  448. styles: {
  449. 'text-align': 'right'
  450. }
  451. },
  452. center: {
  453. styles: {
  454. 'text-align': 'center'
  455. }
  456. }
  457. }
  458. } );
  459. test(
  460. '<p class="align-right">Foo</p>',
  461. '<paragraph align="right">Foo</paragraph>'
  462. );
  463. test(
  464. '<p style="text-align:right">Foo</p>',
  465. '<paragraph align="right">Foo</paragraph>',
  466. '<p class="align-right">Foo</p>'
  467. );
  468. test(
  469. '<p class="align-center">Foo</p>',
  470. '<paragraph align="center">Foo</paragraph>'
  471. );
  472. test(
  473. '<p style="text-align:center">Foo</p>',
  474. '<paragraph align="center">Foo</paragraph>',
  475. '<p class="align-center">Foo</p>'
  476. );
  477. } );
  478. it( 'config.view and config.model have name and key set', () => {
  479. schema.extend( 'image', {
  480. allowAttributes: [ 'source' ]
  481. } );
  482. conversion.attributeToAttribute( {
  483. model: {
  484. name: 'image',
  485. key: 'source'
  486. },
  487. view: {
  488. name: 'img',
  489. key: 'src'
  490. }
  491. } );
  492. test( '<img src="foo.jpg"></img>', '<image source="foo.jpg"></image>' );
  493. } );
  494. } );
  495. function test( input, expectedModel, expectedView = null ) {
  496. loadData( input );
  497. expect( modelStringify( model.document.getRoot() ) ).to.equal( expectedModel );
  498. expect( viewStringify( viewRoot, null, { ignoreRoot: true } ) ).to.equal( expectedView || input );
  499. }
  500. function loadData( input ) {
  501. const parsedView = viewParse( input );
  502. let convertedModel;
  503. model.change( writer => {
  504. convertedModel = viewDispatcher.convert( parsedView, writer );
  505. } );
  506. model.change( writer => {
  507. writer.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, modelRoot.maxOffset ) );
  508. writer.insert( convertedModel, modelRoot, 0 );
  509. } );
  510. }
  511. } );
  512. } );