upcasttable.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module table/converters/upcasttable
  7. */
  8. import { createEmptyTableCell } from '../commands/utils';
  9. /**
  10. * View table element to model table element conversion helper.
  11. *
  12. * This conversion helper converts the table element as well as table rows.
  13. *
  14. * @returns {Function} Conversion helper.
  15. */
  16. export default function upcastTable() {
  17. return dispatcher => {
  18. dispatcher.on( 'element:table', ( evt, data, conversionApi ) => {
  19. const viewTable = data.viewItem;
  20. // When element was already consumed then skip it.
  21. if ( !conversionApi.consumable.test( viewTable, { name: true } ) ) {
  22. return;
  23. }
  24. const { rows, headingRows, headingColumns } = scanTable( viewTable );
  25. // Only set attributes if values is greater then 0.
  26. const attributes = {};
  27. if ( headingColumns ) {
  28. attributes.headingColumns = headingColumns;
  29. }
  30. if ( headingRows ) {
  31. attributes.headingRows = headingRows;
  32. }
  33. const table = conversionApi.writer.createElement( 'table', attributes );
  34. // Insert element on allowed position.
  35. const splitResult = conversionApi.splitToAllowedParent( table, data.modelCursor );
  36. // When there is no split result it means that we can't insert element to model tree, so let's skip it.
  37. if ( !splitResult ) {
  38. return;
  39. }
  40. conversionApi.writer.insert( table, splitResult.position );
  41. conversionApi.consumable.consume( viewTable, { name: true } );
  42. // Upcast table rows in proper order (heading rows first).
  43. rows.forEach( row => conversionApi.convertItem( row, conversionApi.writer.createPositionAt( table, 'end' ) ) );
  44. // Create one row and one table cell for empty table.
  45. if ( table.isEmpty ) {
  46. const row = conversionApi.writer.createElement( 'tableRow' );
  47. conversionApi.writer.insert( row, conversionApi.writer.createPositionAt( table, 'end' ) );
  48. createEmptyTableCell( conversionApi.writer, conversionApi.writer.createPositionAt( row, 'end' ) );
  49. }
  50. // Set conversion result range.
  51. data.modelRange = conversionApi.writer.createRange(
  52. // Range should start before inserted element
  53. conversionApi.writer.createPositionBefore( table ),
  54. // Should end after but we need to take into consideration that children could split our
  55. // element, so we need to move range after parent of the last converted child.
  56. // before: <allowed>[]</allowed>
  57. // after: <allowed>[<converted><child></child></converted><child></child><converted>]</converted></allowed>
  58. conversionApi.writer.createPositionAfter( table )
  59. );
  60. // Now we need to check where the modelCursor should be.
  61. // If we had to split parent to insert our element then we want to continue conversion inside split parent.
  62. //
  63. // before: <allowed><notAllowed>[]</notAllowed></allowed>
  64. // after: <allowed><notAllowed></notAllowed><converted></converted><notAllowed>[]</notAllowed></allowed>
  65. if ( splitResult.cursorParent ) {
  66. data.modelCursor = conversionApi.writer.createPositionAt( splitResult.cursorParent, 0 );
  67. // Otherwise just continue after inserted element.
  68. } else {
  69. data.modelCursor = data.modelRange.end;
  70. }
  71. } );
  72. };
  73. }
  74. export function upcastTableRow() {
  75. return dispatcher => {
  76. dispatcher.on( 'element:tr', ( evt, data ) => {
  77. if ( data.viewItem.isEmpty ) {
  78. evt.stop();
  79. }
  80. }, { priority: 'high' } );
  81. };
  82. }
  83. export function upcastTableCell( elementName ) {
  84. return dispatcher => {
  85. dispatcher.on( `element:${ elementName }`, ( evt, data, conversionApi ) => {
  86. const viewTableCell = data.viewItem;
  87. // When element was already consumed then skip it.
  88. if ( !conversionApi.consumable.test( viewTableCell, { name: true } ) ) {
  89. return;
  90. }
  91. const tableCell = conversionApi.writer.createElement( 'tableCell' );
  92. // Insert element on allowed position.
  93. const splitResult = conversionApi.splitToAllowedParent( tableCell, data.modelCursor );
  94. // When there is no split result it means that we can't insert element to model tree, so let's skip it.
  95. if ( !splitResult ) {
  96. return;
  97. }
  98. conversionApi.writer.insert( tableCell, splitResult.position );
  99. conversionApi.consumable.consume( viewTableCell, { name: true } );
  100. const modelCursor = conversionApi.writer.createPositionAt( tableCell, 0 );
  101. conversionApi.convertChildren( viewTableCell, modelCursor );
  102. // Ensure a paragraph in the model for empty table cells.
  103. if ( !tableCell.childCount ) {
  104. conversionApi.writer.insertElement( 'paragraph', modelCursor );
  105. }
  106. // Set conversion result range.
  107. data.modelRange = conversionApi.writer.createRange(
  108. // Range should start before inserted element
  109. conversionApi.writer.createPositionBefore( tableCell ),
  110. // Should end after but we need to take into consideration that children could split our
  111. // element, so we need to move range after parent of the last converted child.
  112. // before: <allowed>[]</allowed>
  113. // after: <allowed>[<converted><child></child></converted><child></child><converted>]</converted></allowed>
  114. conversionApi.writer.createPositionAfter( tableCell )
  115. );
  116. // Continue after inserted element.
  117. data.modelCursor = data.modelRange.end;
  118. } );
  119. };
  120. }
  121. // Scans table rows and extracts required metadata from the table:
  122. //
  123. // headingRows - The number of rows that go as table headers.
  124. // headingColumns - The maximum number of row headings.
  125. // rows - Sorted `<tr>` elements as they should go into the model - ie. if `<thead>` is inserted after `<tbody>` in the view.
  126. //
  127. // @param {module:engine/view/element~Element} viewTable
  128. // @returns {{headingRows, headingColumns, rows}}
  129. function scanTable( viewTable ) {
  130. const tableMeta = {
  131. headingRows: 0,
  132. headingColumns: 0
  133. };
  134. // The `<tbody>` and `<thead>` sections in the DOM do not have to be in order `<thead>` -> `<tbody>` and there might be more than one
  135. // of them.
  136. // As the model does not have these sections, rows from different sections must be sorted.
  137. // For example, below is a valid HTML table:
  138. //
  139. // <table>
  140. // <tbody><tr><td>2</td></tr></tbody>
  141. // <thead><tr><td>1</td></tr></thead>
  142. // <tbody><tr><td>3</td></tr></tbody>
  143. // </table>
  144. //
  145. // But browsers will render rows in order as: 1 as heading and 2 and 3 as the body.
  146. const headRows = [];
  147. const bodyRows = [];
  148. // Currently the editor does not support more then one <thead> section.
  149. // Only the first <thead> from the view will be used as heading rows and others will be converted to body rows.
  150. let firstTheadElement;
  151. for ( const tableChild of Array.from( viewTable.getChildren() ) ) {
  152. // Only <thead>, <tbody> & <tfoot> from allowed table children can have <tr>s.
  153. // The else is for future purposes (mainly <caption>).
  154. if ( tableChild.name === 'tbody' || tableChild.name === 'thead' || tableChild.name === 'tfoot' ) {
  155. // Save the first <thead> in the table as table header - all other ones will be converted to table body rows.
  156. if ( tableChild.name === 'thead' && !firstTheadElement ) {
  157. firstTheadElement = tableChild;
  158. }
  159. // There might be some extra empty text nodes between the `tr`s.
  160. // Make sure further code operates on `tr`s only. (#145)
  161. const trs = Array.from( tableChild.getChildren() ).filter( el => el.is( 'element', 'tr' ) );
  162. for ( const tr of trs ) {
  163. // This <tr> is a child of a first <thead> element.
  164. if ( tr.parent.name === 'thead' && tr.parent === firstTheadElement ) {
  165. tableMeta.headingRows++;
  166. headRows.push( tr );
  167. } else {
  168. bodyRows.push( tr );
  169. // For other rows check how many column headings this row has.
  170. const headingCols = scanRowForHeadingColumns( tr, tableMeta, firstTheadElement );
  171. if ( headingCols > tableMeta.headingColumns ) {
  172. tableMeta.headingColumns = headingCols;
  173. }
  174. }
  175. }
  176. }
  177. }
  178. tableMeta.rows = [ ...headRows, ...bodyRows ];
  179. return tableMeta;
  180. }
  181. // Scans a `<tr>` element and its children for metadata:
  182. // - For heading row:
  183. // - Adds this row to either the heading or the body rows.
  184. // - Updates the number of heading rows.
  185. // - For body rows:
  186. // - Calculates the number of column headings.
  187. //
  188. // @param {module:engine/view/element~Element} tr
  189. // @returns {Number}
  190. function scanRowForHeadingColumns( tr ) {
  191. let headingColumns = 0;
  192. let index = 0;
  193. // Filter out empty text nodes from tr children.
  194. const children = Array.from( tr.getChildren() )
  195. .filter( child => child.name === 'th' || child.name === 'td' );
  196. // Count starting adjacent <th> elements of a <tr>.
  197. while ( index < children.length && children[ index ].name === 'th' ) {
  198. const th = children[ index ];
  199. // Adjust columns calculation by the number of spanned columns.
  200. const colspan = parseInt( th.getAttribute( 'colspan' ) || 1 );
  201. headingColumns = headingColumns + colspan;
  202. index++;
  203. }
  204. return headingColumns;
  205. }