conversion.js 15 KB

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