downcast.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  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 ), 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. }, { priority: 'normal' } );
  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 = figureElement.getChild( 0 );
  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. }, { priority: 'normal' } );
  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. }, { priority: 'normal' } );
  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 = figureElement.getChild( 0 );
  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 );
  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. }, { priority: 'normal' } );
  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. }, { priority: 'normal' } );
  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 a table cell in the view to a given element name.
  244. //
  245. // @param {module:engine/model/element~Element} tableCell
  246. // @param {String} desiredCellElementName
  247. // @param {Object} conversionApi
  248. // @param {Boolean} asWidget
  249. function renameViewTableCell( tableCell, desiredCellElementName, conversionApi, asWidget ) {
  250. const viewCell = conversionApi.mapper.toViewElement( tableCell );
  251. let renamedCell;
  252. if ( asWidget ) {
  253. const editable = conversionApi.writer.createEditableElement( desiredCellElementName, viewCell.getAttributes() );
  254. renamedCell = toWidgetEditable( editable, conversionApi.writer );
  255. conversionApi.writer.insert( ViewPosition.createAfter( viewCell ), renamedCell );
  256. conversionApi.writer.move( ViewRange.createIn( viewCell ), ViewPosition.createAt( renamedCell ) );
  257. conversionApi.writer.remove( ViewRange.createOn( viewCell ) );
  258. } else {
  259. renamedCell = conversionApi.writer.rename( viewCell, desiredCellElementName );
  260. }
  261. conversionApi.mapper.bindElements( tableCell, renamedCell );
  262. }
  263. // Renames a table cell element in the view according to its location in the table.
  264. //
  265. // @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue
  266. // @param {{headingColumns, headingRows}} tableAttributes
  267. // @param {Object} conversionApi
  268. // @param {Boolean} asWidget
  269. function renameViewTableCellIfRequired( tableWalkerValue, tableAttributes, conversionApi, asWidget ) {
  270. const { cell } = tableWalkerValue;
  271. // Check whether current columnIndex is overlapped by table cells from previous rows.
  272. const desiredCellElementName = getCellElementName( tableWalkerValue, tableAttributes );
  273. const viewCell = conversionApi.mapper.toViewElement( cell );
  274. // If in single change we're converting attribute changes and inserting cell the table cell might not be inserted into view
  275. // because of child conversion is done after parent.
  276. if ( viewCell && viewCell.name !== desiredCellElementName ) {
  277. renameViewTableCell( cell, desiredCellElementName, conversionApi, asWidget );
  278. }
  279. }
  280. // Creates a table cell element in the view.
  281. //
  282. // @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue
  283. // @param {module:engine/view/position~Position} insertPosition
  284. // @param {Object} conversionApi
  285. function createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options ) {
  286. const asWidget = options && options.asWidget;
  287. const cellElementName = getCellElementName( tableWalkerValue, tableAttributes );
  288. const cellElement = asWidget ?
  289. toWidgetEditable( conversionApi.writer.createEditableElement( cellElementName ), conversionApi.writer ) :
  290. conversionApi.writer.createContainerElement( cellElementName );
  291. const tableCell = tableWalkerValue.cell;
  292. conversionApi.mapper.bindElements( tableCell, cellElement );
  293. conversionApi.writer.insert( insertPosition, cellElement );
  294. }
  295. // Creates or returns an existing `<tr>` element from the view.
  296. //
  297. // @param {module:engine/view/element~Element} tableRow
  298. // @param {Number} rowIndex
  299. // @param {module:engine/view/element~Element} tableSection
  300. // @param {Object} conversionApi
  301. // @returns {module:engine/view/element~Element}
  302. function getOrCreateTr( tableRow, rowIndex, tableSection, conversionApi ) {
  303. let trElement = conversionApi.mapper.toViewElement( tableRow );
  304. if ( !trElement ) {
  305. // Will always consume since we're converting <tableRow> element from a parent <table>.
  306. conversionApi.consumable.consume( tableRow, 'insert' );
  307. trElement = conversionApi.writer.createContainerElement( 'tr' );
  308. conversionApi.mapper.bindElements( tableRow, trElement );
  309. const headingRows = tableRow.parent.getAttribute( 'headingRows' ) || 0;
  310. const offset = headingRows > 0 && rowIndex >= headingRows ? rowIndex - headingRows : rowIndex;
  311. const position = ViewPosition.createAt( tableSection, offset );
  312. conversionApi.writer.insert( position, trElement );
  313. }
  314. return trElement;
  315. }
  316. // Returns `th` for heading cells and `td` for other cells for the current table walker value.
  317. //
  318. // @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue
  319. // @param {{headingColumns, headingRows}} tableAttributes
  320. // @returns {String}
  321. function getCellElementName( tableWalkerValue, tableAttributes ) {
  322. const { row, column } = tableWalkerValue;
  323. const { headingColumns, headingRows } = tableAttributes;
  324. // Column heading are all tableCells in the first `columnHeading` rows.
  325. const isColumnHeading = headingRows && headingRows > row;
  326. // So a whole row gets <th> element.
  327. if ( isColumnHeading ) {
  328. return 'th';
  329. }
  330. // Row heading are tableCells which columnIndex is lower then headingColumns.
  331. const isRowHeading = headingColumns && headingColumns > column;
  332. return isRowHeading ? 'th' : 'td';
  333. }
  334. // Returns the table section name for the current table walker value.
  335. //
  336. // @param {Number} row
  337. // @param {{headingColumns, headingRows}} tableAttributes
  338. // @returns {String}
  339. function getSectionName( row, tableAttributes ) {
  340. return row < tableAttributes.headingRows ? 'thead' : 'tbody';
  341. }
  342. // Creates or returns an existing `<tbody>` or `<thead>` element witch caching.
  343. //
  344. // @param {String} sectionName
  345. // @param {module:engine/view/element~Element} viewTable
  346. // @param {Object} conversionApi
  347. // @param {Object} cachedTableSection An object that stores cached elements.
  348. // @returns {module:engine/view/containerelement~ContainerElement}
  349. function getOrCreateTableSection( sectionName, viewTable, conversionApi ) {
  350. const viewTableSection = getExistingTableSectionElement( sectionName, viewTable );
  351. return viewTableSection ? viewTableSection : createTableSection( sectionName, viewTable, conversionApi );
  352. }
  353. // Finds an existing `<tbody>` or `<thead>` element or returns undefined.
  354. //
  355. // @param {String} sectionName
  356. // @param {module:engine/view/element~Element} tableElement
  357. // @param {Object} conversionApi
  358. function getExistingTableSectionElement( sectionName, tableElement ) {
  359. for ( const tableSection of tableElement.getChildren() ) {
  360. if ( tableSection.name == sectionName ) {
  361. return tableSection;
  362. }
  363. }
  364. }
  365. // Creates a table section at the end of the table.
  366. //
  367. // @param {String} sectionName
  368. // @param {module:engine/view/element~Element} tableElement
  369. // @param {Object} conversionApi
  370. // @returns {module:engine/view/containerelement~ContainerElement}
  371. function createTableSection( sectionName, tableElement, conversionApi ) {
  372. const tableChildElement = conversionApi.writer.createContainerElement( sectionName );
  373. conversionApi.writer.insert( ViewPosition.createAt( tableElement, sectionName == 'tbody' ? 'end' : 'start' ), tableChildElement );
  374. return tableChildElement;
  375. }
  376. // Removes an existing `<tbody>` or `<thead>` element if it is empty.
  377. //
  378. // @param {String} sectionName
  379. // @param {module:engine/view/element~Element} tableElement
  380. // @param {Object} conversionApi
  381. function removeTableSectionIfEmpty( sectionName, tableElement, conversionApi ) {
  382. const tableSection = getExistingTableSectionElement( sectionName, tableElement );
  383. if ( tableSection && tableSection.childCount === 0 ) {
  384. conversionApi.writer.remove( ViewRange.createOn( tableSection ) );
  385. }
  386. }
  387. // Moves view table rows associated with passed model rows to the provided table section element.
  388. //
  389. // @param {Array.<module:engine/model/element~Element>} rowsToMove
  390. // @param {module:engine/view/element~Element} viewTableSection
  391. // @param {Object} conversionApi
  392. // @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags.
  393. function moveViewRowsToTableSection( rowsToMove, viewTableSection, conversionApi, offset ) {
  394. for ( const tableRow of rowsToMove ) {
  395. const viewTableRow = conversionApi.mapper.toViewElement( tableRow );
  396. conversionApi.writer.move( ViewRange.createOn( viewTableRow ), ViewPosition.createAt( viewTableSection, offset ) );
  397. }
  398. }