downcast.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. /**
  2. * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module table/converters/downcast
  7. */
  8. import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
  9. import ViewRange from '@ckeditor/ckeditor5-engine/src/view/range';
  10. import TableWalker from './../tablewalker';
  11. import { toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
  12. import { toTableWidget } from '../utils';
  13. /**
  14. * Model table element to view table element conversion helper.
  15. *
  16. * This conversion helper creates the whole table element with child elements.
  17. *
  18. * @param {Object} options
  19. * @param {Boolean} options.asWidget If set to `true`, the downcast conversion will produce a widget.
  20. * @returns {Function} Conversion helper.
  21. */
  22. export function downcastInsertTable( options = {} ) {
  23. return dispatcher => dispatcher.on( 'insert:table', ( evt, data, conversionApi ) => {
  24. const table = data.item;
  25. if ( !conversionApi.consumable.consume( table, 'insert' ) ) {
  26. return;
  27. }
  28. // Consume attributes if present to not fire attribute change downcast
  29. conversionApi.consumable.consume( table, 'attribute:headingRows:table' );
  30. conversionApi.consumable.consume( table, 'attribute:headingColumns:table' );
  31. const asWidget = options && options.asWidget;
  32. const figureElement = conversionApi.writer.createContainerElement( 'figure', { class: 'table' } );
  33. const tableElement = conversionApi.writer.createContainerElement( 'table' );
  34. conversionApi.writer.insert( ViewPosition.createAt( figureElement, 0 ), tableElement );
  35. let tableWidget;
  36. if ( asWidget ) {
  37. tableWidget = toTableWidget( figureElement, conversionApi.writer );
  38. }
  39. const tableWalker = new TableWalker( table );
  40. const tableAttributes = {
  41. headingRows: table.getAttribute( 'headingRows' ) || 0,
  42. headingColumns: table.getAttribute( 'headingColumns' ) || 0
  43. };
  44. for ( const tableWalkerValue of tableWalker ) {
  45. const { row, cell } = tableWalkerValue;
  46. const tableSection = getOrCreateTableSection( getSectionName( row, tableAttributes ), tableElement, conversionApi );
  47. const tableRow = table.getChild( row );
  48. // Check if row was converted
  49. const trElement = getOrCreateTr( tableRow, row, tableSection, conversionApi );
  50. // Consume table cell - it will be always consumed as we convert whole table at once.
  51. conversionApi.consumable.consume( cell, 'insert' );
  52. const insertPosition = ViewPosition.createAt( trElement, 'end' );
  53. createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options );
  54. }
  55. const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
  56. conversionApi.mapper.bindElements( table, asWidget ? tableWidget : figureElement );
  57. conversionApi.writer.insert( viewPosition, asWidget ? tableWidget : figureElement );
  58. } );
  59. }
  60. /**
  61. * Model row element to view `<tr>` element conversion helper.
  62. *
  63. * This conversion helper creates the whole `<tr>` element with child elements.
  64. *
  65. * @returns {Function} Conversion helper.
  66. */
  67. export function downcastInsertRow( options = {} ) {
  68. return dispatcher => dispatcher.on( 'insert:tableRow', ( evt, data, conversionApi ) => {
  69. const tableRow = data.item;
  70. if ( !conversionApi.consumable.consume( tableRow, 'insert' ) ) {
  71. return;
  72. }
  73. const table = tableRow.parent;
  74. const figureElement = conversionApi.mapper.toViewElement( table );
  75. const tableElement = getViewTable( figureElement );
  76. const row = table.getChildIndex( tableRow );
  77. const tableWalker = new TableWalker( table, { startRow: row, endRow: row } );
  78. const tableAttributes = {
  79. headingRows: table.getAttribute( 'headingRows' ) || 0,
  80. headingColumns: table.getAttribute( 'headingColumns' ) || 0
  81. };
  82. for ( const tableWalkerValue of tableWalker ) {
  83. const tableSection = getOrCreateTableSection( getSectionName( row, tableAttributes ), tableElement, conversionApi );
  84. const trElement = getOrCreateTr( tableRow, row, tableSection, conversionApi );
  85. // Consume table cell - it will be always consumed as we convert whole row at once.
  86. conversionApi.consumable.consume( tableWalkerValue.cell, 'insert' );
  87. const insertPosition = ViewPosition.createAt( trElement, 'end' );
  88. createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options );
  89. }
  90. } );
  91. }
  92. /**
  93. * Model table cell element to view `<td>` or `<th>` element conversion helper.
  94. *
  95. * This conversion helper will create proper `<th>` elements for table cells that are in the heading section (heading row or column)
  96. * and `<td>` otherwise.
  97. *
  98. * @returns {Function} Conversion helper.
  99. */
  100. export function downcastInsertCell( options = {} ) {
  101. return dispatcher => dispatcher.on( 'insert:tableCell', ( evt, data, conversionApi ) => {
  102. const tableCell = data.item;
  103. if ( !conversionApi.consumable.consume( tableCell, 'insert' ) ) {
  104. return;
  105. }
  106. const tableRow = tableCell.parent;
  107. const table = tableRow.parent;
  108. const rowIndex = table.getChildIndex( tableRow );
  109. const tableWalker = new TableWalker( table, { startRow: rowIndex, endRow: rowIndex } );
  110. const tableAttributes = {
  111. headingRows: table.getAttribute( 'headingRows' ) || 0,
  112. headingColumns: table.getAttribute( 'headingColumns' ) || 0
  113. };
  114. // We need to iterate over a table in order to get proper row & column values from a walker
  115. for ( const tableWalkerValue of tableWalker ) {
  116. if ( tableWalkerValue.cell === tableCell ) {
  117. const trElement = conversionApi.mapper.toViewElement( tableRow );
  118. const insertPosition = ViewPosition.createAt( trElement, tableRow.getChildIndex( tableCell ) );
  119. createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options );
  120. // No need to iterate further.
  121. return;
  122. }
  123. }
  124. } );
  125. }
  126. /**
  127. * Conversion helper that acts on heading rows table attribute change.
  128. *
  129. * This converter will:
  130. *
  131. * * Rename `<td>` to `<th>` elements or vice versa depending on headings.
  132. * * Create `<thead>` or `<tbody>` elements if needed.
  133. * * Remove empty `<thead>` or `<tbody>` if needed.
  134. *
  135. * @returns {Function} Conversion helper.
  136. */
  137. export function downcastTableHeadingRowsChange( options = {} ) {
  138. const asWidget = !!options.asWidget;
  139. return dispatcher => dispatcher.on( 'attribute:headingRows:table', ( evt, data, conversionApi ) => {
  140. const table = data.item;
  141. if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
  142. return;
  143. }
  144. const figureElement = conversionApi.mapper.toViewElement( table );
  145. const viewTable = getViewTable( figureElement );
  146. const oldRows = data.attributeOldValue;
  147. const newRows = data.attributeNewValue;
  148. // The head section has grown so move rows from <tbody> to <thead>.
  149. if ( newRows > oldRows ) {
  150. // Filter out only those rows that are in wrong section.
  151. const rowsToMove = Array.from( table.getChildren() ).filter( ( { index } ) => isBetween( index, oldRows - 1, newRows ) );
  152. const viewTableHead = getOrCreateTableSection( 'thead', viewTable, conversionApi );
  153. moveViewRowsToTableSection( rowsToMove, viewTableHead, conversionApi, 'end' );
  154. // Rename all table cells from moved rows to 'th' as they lands in <thead>.
  155. for ( const tableRow of rowsToMove ) {
  156. for ( const tableCell of tableRow.getChildren() ) {
  157. renameViewTableCell( tableCell, 'th', conversionApi, asWidget );
  158. }
  159. }
  160. // Cleanup: this will remove any empty section from the view which may happen when moving all rows from a table section.
  161. removeTableSectionIfEmpty( 'tbody', viewTable, conversionApi );
  162. }
  163. // The head section has shrunk so move rows from <thead> to <tbody>.
  164. else {
  165. // Filter out only those rows that are in wrong section.
  166. const rowsToMove = Array.from( table.getChildren() )
  167. .filter( ( { index } ) => isBetween( index, newRows - 1, oldRows ) )
  168. .reverse(); // The rows will be moved from <thead> to <tbody> in reverse order at the beginning of a <tbody>.
  169. const viewTableBody = getOrCreateTableSection( 'tbody', viewTable, conversionApi );
  170. moveViewRowsToTableSection( rowsToMove, viewTableBody, conversionApi, 0 );
  171. // Check if cells moved from <thead> to <tbody> requires renaming to <td> as this depends on current heading columns attribute.
  172. const tableWalker = new TableWalker( table, { startRow: newRows ? newRows - 1 : newRows, endRow: oldRows - 1 } );
  173. const tableAttributes = {
  174. headingRows: table.getAttribute( 'headingRows' ) || 0,
  175. headingColumns: table.getAttribute( 'headingColumns' ) || 0
  176. };
  177. for ( const tableWalkerValue of tableWalker ) {
  178. renameViewTableCellIfRequired( tableWalkerValue, tableAttributes, conversionApi, asWidget );
  179. }
  180. // Cleanup: this will remove any empty section from the view which may happen when moving all rows from a table section.
  181. removeTableSectionIfEmpty( 'thead', viewTable, conversionApi );
  182. }
  183. function isBetween( index, lower, upper ) {
  184. return index > lower && index < upper;
  185. }
  186. } );
  187. }
  188. /**
  189. * Conversion helper that acts on heading columns table attribute change.
  190. *
  191. * Depending on changed attributes this converter will rename `<td` to `<th>` elements or vice versa depending of the cell column index.
  192. *
  193. * @returns {Function} Conversion helper.
  194. */
  195. export function downcastTableHeadingColumnsChange( options = {} ) {
  196. const asWidget = !!options.asWidget;
  197. return dispatcher => dispatcher.on( 'attribute:headingColumns:table', ( evt, data, conversionApi ) => {
  198. const table = data.item;
  199. if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
  200. return;
  201. }
  202. const tableAttributes = {
  203. headingRows: table.getAttribute( 'headingRows' ) || 0,
  204. headingColumns: table.getAttribute( 'headingColumns' ) || 0
  205. };
  206. const oldColumns = data.attributeOldValue;
  207. const newColumns = data.attributeNewValue;
  208. const lastColumnToCheck = ( oldColumns > newColumns ? oldColumns : newColumns ) - 1;
  209. for ( const tableWalkerValue of new TableWalker( table ) ) {
  210. // Skip cells that were not in heading section before and after the change.
  211. if ( tableWalkerValue.column > lastColumnToCheck ) {
  212. continue;
  213. }
  214. renameViewTableCellIfRequired( tableWalkerValue, tableAttributes, conversionApi, asWidget );
  215. }
  216. } );
  217. }
  218. /**
  219. * Conversion helper that acts on a removed row.
  220. *
  221. * @returns {Function} Conversion helper.
  222. */
  223. export function downcastRemoveRow() {
  224. return dispatcher => dispatcher.on( 'remove:tableRow', ( evt, data, conversionApi ) => {
  225. // Prevent default remove converter.
  226. evt.stop();
  227. const viewStart = conversionApi.mapper.toViewPosition( data.position ).getLastMatchingPosition( value => !value.item.is( 'tr' ) );
  228. const viewItem = viewStart.nodeAfter;
  229. const tableSection = viewItem.parent;
  230. // Remove associated <tr> from the view.
  231. const removeRange = ViewRange.createOn( viewItem );
  232. const removed = conversionApi.writer.remove( removeRange );
  233. for ( const child of ViewRange.createIn( removed ).getItems() ) {
  234. conversionApi.mapper.unbindViewElement( child );
  235. }
  236. // Check if table section has any children left - if not remove it from the view.
  237. if ( !tableSection.childCount ) {
  238. // No need to unbind anything as table section is not represented in the model.
  239. conversionApi.writer.remove( ViewRange.createOn( tableSection ) );
  240. }
  241. }, { priority: 'higher' } );
  242. }
  243. // Renames an existing table cell in the view to a given element name.
  244. //
  245. // **Note** This method will not do anything if a view table cell was not yet converted.
  246. //
  247. // @param {module:engine/model/element~Element} tableCell
  248. // @param {String} desiredCellElementName
  249. // @param {Object} conversionApi
  250. // @param {Boolean} asWidget
  251. function renameViewTableCell( tableCell, desiredCellElementName, conversionApi, asWidget ) {
  252. const viewCell = conversionApi.mapper.toViewElement( tableCell );
  253. // View cell might be not yet converted - skip it as it will be properly created by cell converter later on.
  254. if ( !viewCell ) {
  255. return;
  256. }
  257. let renamedCell;
  258. if ( asWidget ) {
  259. const editable = conversionApi.writer.createEditableElement( desiredCellElementName, viewCell.getAttributes() );
  260. renamedCell = toWidgetEditable( editable, conversionApi.writer );
  261. conversionApi.writer.insert( ViewPosition.createAfter( viewCell ), renamedCell );
  262. conversionApi.writer.move( ViewRange.createIn( viewCell ), ViewPosition.createAt( renamedCell, 0 ) );
  263. conversionApi.writer.remove( ViewRange.createOn( viewCell ) );
  264. } else {
  265. renamedCell = conversionApi.writer.rename( desiredCellElementName, viewCell );
  266. }
  267. conversionApi.mapper.bindElements( tableCell, renamedCell );
  268. }
  269. // Renames a table cell element in the view according to its location in the table.
  270. //
  271. // @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue
  272. // @param {{headingColumns, headingRows}} tableAttributes
  273. // @param {Object} conversionApi
  274. // @param {Boolean} asWidget
  275. function renameViewTableCellIfRequired( tableWalkerValue, tableAttributes, conversionApi, asWidget ) {
  276. const { cell } = tableWalkerValue;
  277. // Check whether current columnIndex is overlapped by table cells from previous rows.
  278. const desiredCellElementName = getCellElementName( tableWalkerValue, tableAttributes );
  279. const viewCell = conversionApi.mapper.toViewElement( cell );
  280. // If in single change we're converting attribute changes and inserting cell the table cell might not be inserted into view
  281. // because of child conversion is done after parent.
  282. if ( viewCell && viewCell.name !== desiredCellElementName ) {
  283. renameViewTableCell( cell, desiredCellElementName, conversionApi, asWidget );
  284. }
  285. }
  286. // Creates a table cell element in the view.
  287. //
  288. // @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue
  289. // @param {module:engine/view/position~Position} insertPosition
  290. // @param {Object} conversionApi
  291. function createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options ) {
  292. const asWidget = options && options.asWidget;
  293. const cellElementName = getCellElementName( tableWalkerValue, tableAttributes );
  294. const cellElement = asWidget ?
  295. toWidgetEditable( conversionApi.writer.createEditableElement( cellElementName ), conversionApi.writer ) :
  296. conversionApi.writer.createContainerElement( cellElementName );
  297. const tableCell = tableWalkerValue.cell;
  298. const isSingleParagraph = tableCell.childCount === 1 && tableCell.getChild( 0 ).name === 'paragraph';
  299. conversionApi.writer.insert( insertPosition, cellElement );
  300. if ( isSingleParagraph ) {
  301. const innerParagraph = tableCell.getChild( 0 );
  302. const paragraphInsertPosition = ViewPosition.createAt( cellElement, 'end' );
  303. conversionApi.consumable.consume( innerParagraph, 'insert' );
  304. if ( options.asWidget ) {
  305. const containerName = [ ...innerParagraph.getAttributeKeys() ].length ? 'p' : 'span';
  306. const fakeParagraph = conversionApi.writer.createContainerElement( containerName );
  307. conversionApi.mapper.bindElements( innerParagraph, fakeParagraph );
  308. conversionApi.writer.insert( paragraphInsertPosition, fakeParagraph );
  309. conversionApi.mapper.bindElements( tableCell, cellElement );
  310. } else {
  311. conversionApi.mapper.bindElements( tableCell, cellElement );
  312. conversionApi.mapper.bindElements( innerParagraph, cellElement );
  313. }
  314. } else {
  315. conversionApi.mapper.bindElements( tableCell, cellElement );
  316. }
  317. }
  318. // Creates or returns an existing `<tr>` element from the view.
  319. //
  320. // @param {module:engine/view/element~Element} tableRow
  321. // @param {Number} rowIndex
  322. // @param {module:engine/view/element~Element} tableSection
  323. // @param {Object} conversionApi
  324. // @returns {module:engine/view/element~Element}
  325. function getOrCreateTr( tableRow, rowIndex, tableSection, conversionApi ) {
  326. let trElement = conversionApi.mapper.toViewElement( tableRow );
  327. if ( !trElement ) {
  328. // Will always consume since we're converting <tableRow> element from a parent <table>.
  329. conversionApi.consumable.consume( tableRow, 'insert' );
  330. trElement = conversionApi.writer.createContainerElement( 'tr' );
  331. conversionApi.mapper.bindElements( tableRow, trElement );
  332. const headingRows = tableRow.parent.getAttribute( 'headingRows' ) || 0;
  333. const offset = headingRows > 0 && rowIndex >= headingRows ? rowIndex - headingRows : rowIndex;
  334. const position = ViewPosition.createAt( tableSection, offset );
  335. conversionApi.writer.insert( position, trElement );
  336. }
  337. return trElement;
  338. }
  339. // Returns `th` for heading cells and `td` for other cells for the current table walker value.
  340. //
  341. // @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue
  342. // @param {{headingColumns, headingRows}} tableAttributes
  343. // @returns {String}
  344. function getCellElementName( tableWalkerValue, tableAttributes ) {
  345. const { row, column } = tableWalkerValue;
  346. const { headingColumns, headingRows } = tableAttributes;
  347. // Column heading are all tableCells in the first `columnHeading` rows.
  348. const isColumnHeading = headingRows && headingRows > row;
  349. // So a whole row gets <th> element.
  350. if ( isColumnHeading ) {
  351. return 'th';
  352. }
  353. // Row heading are tableCells which columnIndex is lower then headingColumns.
  354. const isRowHeading = headingColumns && headingColumns > column;
  355. return isRowHeading ? 'th' : 'td';
  356. }
  357. // Returns the table section name for the current table walker value.
  358. //
  359. // @param {Number} row
  360. // @param {{headingColumns, headingRows}} tableAttributes
  361. // @returns {String}
  362. function getSectionName( row, tableAttributes ) {
  363. return row < tableAttributes.headingRows ? 'thead' : 'tbody';
  364. }
  365. // Creates or returns an existing `<tbody>` or `<thead>` element witch caching.
  366. //
  367. // @param {String} sectionName
  368. // @param {module:engine/view/element~Element} viewTable
  369. // @param {Object} conversionApi
  370. // @param {Object} cachedTableSection An object that stores cached elements.
  371. // @returns {module:engine/view/containerelement~ContainerElement}
  372. function getOrCreateTableSection( sectionName, viewTable, conversionApi ) {
  373. const viewTableSection = getExistingTableSectionElement( sectionName, viewTable );
  374. return viewTableSection ? viewTableSection : createTableSection( sectionName, viewTable, conversionApi );
  375. }
  376. // Finds an existing `<tbody>` or `<thead>` element or returns undefined.
  377. //
  378. // @param {String} sectionName
  379. // @param {module:engine/view/element~Element} tableElement
  380. // @param {Object} conversionApi
  381. function getExistingTableSectionElement( sectionName, tableElement ) {
  382. for ( const tableSection of tableElement.getChildren() ) {
  383. if ( tableSection.name == sectionName ) {
  384. return tableSection;
  385. }
  386. }
  387. }
  388. // Creates a table section at the end of the table.
  389. //
  390. // @param {String} sectionName
  391. // @param {module:engine/view/element~Element} tableElement
  392. // @param {Object} conversionApi
  393. // @returns {module:engine/view/containerelement~ContainerElement}
  394. function createTableSection( sectionName, tableElement, conversionApi ) {
  395. const tableChildElement = conversionApi.writer.createContainerElement( sectionName );
  396. conversionApi.writer.insert( ViewPosition.createAt( tableElement, sectionName == 'tbody' ? 'end' : 0 ), tableChildElement );
  397. return tableChildElement;
  398. }
  399. // Removes an existing `<tbody>` or `<thead>` element if it is empty.
  400. //
  401. // @param {String} sectionName
  402. // @param {module:engine/view/element~Element} tableElement
  403. // @param {Object} conversionApi
  404. function removeTableSectionIfEmpty( sectionName, tableElement, conversionApi ) {
  405. const tableSection = getExistingTableSectionElement( sectionName, tableElement );
  406. if ( tableSection && tableSection.childCount === 0 ) {
  407. conversionApi.writer.remove( ViewRange.createOn( tableSection ) );
  408. }
  409. }
  410. // Moves view table rows associated with passed model rows to the provided table section element.
  411. //
  412. // **Note** This method will skip not converted table rows.
  413. //
  414. // @param {Array.<module:engine/model/element~Element>} rowsToMove
  415. // @param {module:engine/view/element~Element} viewTableSection
  416. // @param {Object} conversionApi
  417. // @param {Number|'end'|'before'|'after'} offset Offset or one of the flags.
  418. function moveViewRowsToTableSection( rowsToMove, viewTableSection, conversionApi, offset ) {
  419. for ( const tableRow of rowsToMove ) {
  420. const viewTableRow = conversionApi.mapper.toViewElement( tableRow );
  421. // View table row might be not yet converted - skip it as it will be properly created by cell converter later on.
  422. if ( viewTableRow ) {
  423. conversionApi.writer.move( ViewRange.createOn( viewTableRow ), ViewPosition.createAt( viewTableSection, offset ) );
  424. }
  425. }
  426. }
  427. // Properly finds '<table>' element inside `<figure>` widget.
  428. //
  429. // @param {module:engine/view/element~Element} viewFigure
  430. function getViewTable( viewFigure ) {
  431. for ( const child of viewFigure.getChildren() ) {
  432. if ( child.name === 'table' ) {
  433. return child;
  434. }
  435. }
  436. }