8
0

upcasttable.js 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  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 '../utils/common';
  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. /**
  75. * Conversion helper that skips empty <tr> from upcasting at the beginning of the table.
  76. *
  77. * Empty row is considered a table model error if there are no cells spanned over that row.
  78. *
  79. * @returns {Function} Conversion helper.
  80. */
  81. export function skipEmptyTableRow() {
  82. return dispatcher => {
  83. dispatcher.on( 'element:tr', ( evt, data ) => {
  84. if ( data.viewItem.isEmpty && data.modelCursor.index == 0 ) {
  85. evt.stop();
  86. }
  87. }, { priority: 'high' } );
  88. };
  89. }
  90. export function upcastTableCell( elementName ) {
  91. return dispatcher => {
  92. dispatcher.on( `element:${ elementName }`, ( evt, data, conversionApi ) => {
  93. const viewTableCell = data.viewItem;
  94. // When element was already consumed then skip it.
  95. if ( !conversionApi.consumable.test( viewTableCell, { name: true } ) ) {
  96. return;
  97. }
  98. const tableCell = conversionApi.writer.createElement( 'tableCell' );
  99. // Insert element on allowed position.
  100. const splitResult = conversionApi.splitToAllowedParent( tableCell, data.modelCursor );
  101. // When there is no split result it means that we can't insert element to model tree, so let's skip it.
  102. if ( !splitResult ) {
  103. return;
  104. }
  105. conversionApi.writer.insert( tableCell, splitResult.position );
  106. conversionApi.consumable.consume( viewTableCell, { name: true } );
  107. const modelCursor = conversionApi.writer.createPositionAt( tableCell, 0 );
  108. conversionApi.convertChildren( viewTableCell, modelCursor );
  109. // Ensure a paragraph in the model for empty table cells.
  110. if ( !tableCell.childCount ) {
  111. conversionApi.writer.insertElement( 'paragraph', modelCursor );
  112. }
  113. // Set conversion result range.
  114. data.modelRange = conversionApi.writer.createRange(
  115. // Range should start before inserted element
  116. conversionApi.writer.createPositionBefore( tableCell ),
  117. // Should end after but we need to take into consideration that children could split our
  118. // element, so we need to move range after parent of the last converted child.
  119. // before: <allowed>[]</allowed>
  120. // after: <allowed>[<converted><child></child></converted><child></child><converted>]</converted></allowed>
  121. conversionApi.writer.createPositionAfter( tableCell )
  122. );
  123. // Continue after inserted element.
  124. data.modelCursor = data.modelRange.end;
  125. } );
  126. };
  127. }
  128. // Scans table rows and extracts required metadata from the table:
  129. //
  130. // headingRows - The number of rows that go as table headers.
  131. // headingColumns - The maximum number of row headings.
  132. // rows - Sorted `<tr>` elements as they should go into the model - ie. if `<thead>` is inserted after `<tbody>` in the view.
  133. //
  134. // @param {module:engine/view/element~Element} viewTable
  135. // @returns {{headingRows, headingColumns, rows}}
  136. function scanTable( viewTable ) {
  137. const tableMeta = {
  138. headingRows: 0,
  139. headingColumns: 0
  140. };
  141. // The `<tbody>` and `<thead>` sections in the DOM do not have to be in order `<thead>` -> `<tbody>` and there might be more than one
  142. // of them.
  143. // As the model does not have these sections, rows from different sections must be sorted.
  144. // For example, below is a valid HTML table:
  145. //
  146. // <table>
  147. // <tbody><tr><td>2</td></tr></tbody>
  148. // <thead><tr><td>1</td></tr></thead>
  149. // <tbody><tr><td>3</td></tr></tbody>
  150. // </table>
  151. //
  152. // But browsers will render rows in order as: 1 as heading and 2 and 3 as the body.
  153. const headRows = [];
  154. const bodyRows = [];
  155. // Currently the editor does not support more then one <thead> section.
  156. // Only the first <thead> from the view will be used as heading rows and others will be converted to body rows.
  157. let firstTheadElement;
  158. for ( const tableChild of Array.from( viewTable.getChildren() ) ) {
  159. // Only <thead>, <tbody> & <tfoot> from allowed table children can have <tr>s.
  160. // The else is for future purposes (mainly <caption>).
  161. if ( tableChild.name === 'tbody' || tableChild.name === 'thead' || tableChild.name === 'tfoot' ) {
  162. // Save the first <thead> in the table as table header - all other ones will be converted to table body rows.
  163. if ( tableChild.name === 'thead' && !firstTheadElement ) {
  164. firstTheadElement = tableChild;
  165. }
  166. // There might be some extra empty text nodes between the `tr`s.
  167. // Make sure further code operates on `tr`s only. (#145)
  168. const trs = Array.from( tableChild.getChildren() ).filter( el => el.is( 'element', 'tr' ) );
  169. for ( const tr of trs ) {
  170. // This <tr> is a child of a first <thead> element.
  171. if ( tr.parent.name === 'thead' && tr.parent === firstTheadElement ) {
  172. tableMeta.headingRows++;
  173. headRows.push( tr );
  174. } else {
  175. bodyRows.push( tr );
  176. // For other rows check how many column headings this row has.
  177. const headingCols = scanRowForHeadingColumns( tr, tableMeta, firstTheadElement );
  178. if ( headingCols > tableMeta.headingColumns ) {
  179. tableMeta.headingColumns = headingCols;
  180. }
  181. }
  182. }
  183. }
  184. }
  185. tableMeta.rows = [ ...headRows, ...bodyRows ];
  186. return tableMeta;
  187. }
  188. // Scans a `<tr>` element and its children for metadata:
  189. // - For heading row:
  190. // - Adds this row to either the heading or the body rows.
  191. // - Updates the number of heading rows.
  192. // - For body rows:
  193. // - Calculates the number of column headings.
  194. //
  195. // @param {module:engine/view/element~Element} tr
  196. // @returns {Number}
  197. function scanRowForHeadingColumns( tr ) {
  198. let headingColumns = 0;
  199. let index = 0;
  200. // Filter out empty text nodes from tr children.
  201. const children = Array.from( tr.getChildren() )
  202. .filter( child => child.name === 'th' || child.name === 'td' );
  203. // Count starting adjacent <th> elements of a <tr>.
  204. while ( index < children.length && children[ index ].name === 'th' ) {
  205. const th = children[ index ];
  206. // Adjust columns calculation by the number of spanned columns.
  207. const colspan = parseInt( th.getAttribute( 'colspan' ) || 1 );
  208. headingColumns = headingColumns + colspan;
  209. index++;
  210. }
  211. return headingColumns;
  212. }