mergecellscommand.js 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  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/commands/mergecellscommand
  7. */
  8. import Command from '@ckeditor/ckeditor5-core/src/command';
  9. import TableWalker from '../tablewalker';
  10. import { findAncestor, updateNumericAttribute } from './utils';
  11. import TableUtils from '../tableutils';
  12. import { getSelectionAffectedTableCells } from '../utils';
  13. /**
  14. * The merge cells command.
  15. *
  16. * The command is registered by {@link module:table/tableediting~TableEditing} as `'mergeTableCellRight'`, `'mergeTableCellLeft'`,
  17. * `'mergeTableCellUp'` and `'mergeTableCellDown'` editor commands.
  18. *
  19. * To merge a table cell at the current selection with another cell, execute the command corresponding with the preferred direction.
  20. *
  21. * For example, to merge with a cell to the right:
  22. *
  23. * editor.execute( 'mergeTableCellRight' );
  24. *
  25. * **Note**: If a table cell has a different [`rowspan`](https://www.w3.org/TR/html50/tabular-data.html#attr-tdth-rowspan)
  26. * (for `'mergeTableCellRight'` and `'mergeTableCellLeft'`) or [`colspan`](https://www.w3.org/TR/html50/tabular-data.html#attr-tdth-colspan)
  27. * (for `'mergeTableCellUp'` and `'mergeTableCellDown'`), the command will be disabled.
  28. *
  29. * @extends module:core/command~Command
  30. */
  31. export default class MergeCellsCommand extends Command {
  32. /**
  33. * @inheritDoc
  34. */
  35. refresh() {
  36. this.isEnabled = canMergeCells( this.editor.model.document.selection, this.editor.plugins.get( TableUtils ) );
  37. }
  38. /**
  39. * Executes the command.
  40. *
  41. * Depending on the command's {@link #direction} value, it will merge the cell that is to the `'left'`, `'right'`, `'up'` or `'down'`.
  42. *
  43. * @fires execute
  44. */
  45. execute() {
  46. const model = this.editor.model;
  47. const tableUtils = this.editor.plugins.get( TableUtils );
  48. model.change( writer => {
  49. const selectedTableCells = [ ... this.editor.model.document.selection.getRanges() ].map( range => range.start.nodeAfter );
  50. const firstTableCell = selectedTableCells.shift();
  51. // TODO: this shouldn't be necessary (right now the selection could overlap existing.
  52. writer.setSelection( firstTableCell, 'in' );
  53. const { row, column } = tableUtils.getCellLocation( firstTableCell );
  54. const colspan = parseInt( firstTableCell.getAttribute( 'colspan' ) || 1 );
  55. const rowspan = parseInt( firstTableCell.getAttribute( 'rowspan' ) || 1 );
  56. let rightMax = column + colspan;
  57. let bottomMax = row + rowspan;
  58. const rowsToCheck = new Set();
  59. for ( const tableCell of selectedTableCells ) {
  60. const { row, column } = tableUtils.getCellLocation( tableCell );
  61. const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
  62. const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
  63. if ( column + colspan > rightMax ) {
  64. rightMax = column + colspan;
  65. }
  66. if ( row + rowspan > bottomMax ) {
  67. bottomMax = row + rowspan;
  68. }
  69. }
  70. for ( const tableCell of selectedTableCells ) {
  71. rowsToCheck.add( tableCell.parent );
  72. mergeTableCells( tableCell, firstTableCell, writer );
  73. }
  74. // Update table cell span attribute and merge set selection on merged contents.
  75. updateNumericAttribute( 'colspan', rightMax - column, firstTableCell, writer );
  76. updateNumericAttribute( 'rowspan', bottomMax - row, firstTableCell, writer );
  77. writer.setSelection( firstTableCell, 'in' );
  78. // Remove empty rows after merging table cells.
  79. for ( const row of rowsToCheck ) {
  80. if ( !row.childCount ) {
  81. removeEmptyRow( row, writer );
  82. }
  83. }
  84. } );
  85. }
  86. }
  87. // Properly removes empty row from a table. Will update `rowspan` attribute of cells that overlaps removed row.
  88. //
  89. // @param {module:engine/model/element~Element} removedTableCellRow
  90. // @param {module:engine/model/writer~Writer} writer
  91. function removeEmptyRow( removedTableCellRow, writer ) {
  92. const table = removedTableCellRow.parent;
  93. const removedRowIndex = table.getChildIndex( removedTableCellRow );
  94. for ( const { cell, row, rowspan } of new TableWalker( table, { endRow: removedRowIndex } ) ) {
  95. const overlapsRemovedRow = row + rowspan - 1 >= removedRowIndex;
  96. if ( overlapsRemovedRow ) {
  97. updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer );
  98. }
  99. }
  100. writer.remove( removedTableCellRow );
  101. }
  102. // Merges two table cells - will ensure that after merging cells with empty paragraph the result table cell will only have one paragraph.
  103. // If one of the merged table cell is empty the merged table cell will have contents of the non-empty table cell.
  104. // If both are empty the merged table cell will have only one empty paragraph.
  105. //
  106. // @param {module:engine/model/element~Element} cellToRemove
  107. // @param {module:engine/model/element~Element} cellToExpand
  108. // @param {module:engine/model/writer~Writer} writer
  109. function mergeTableCells( cellToRemove, cellToExpand, writer ) {
  110. if ( !isEmpty( cellToRemove ) ) {
  111. if ( isEmpty( cellToExpand ) ) {
  112. writer.remove( writer.createRangeIn( cellToExpand ) );
  113. }
  114. writer.move( writer.createRangeIn( cellToRemove ), writer.createPositionAt( cellToExpand, 'end' ) );
  115. }
  116. // Remove merged table cell.
  117. writer.remove( cellToRemove );
  118. }
  119. // Checks if passed table cell contains empty paragraph.
  120. //
  121. // @param {module:engine/model/element~Element} tableCell
  122. // @returns {Boolean}
  123. function isEmpty( tableCell ) {
  124. return tableCell.childCount == 1 && tableCell.getChild( 0 ).is( 'paragraph' ) && tableCell.getChild( 0 ).isEmpty;
  125. }
  126. // Check if selection contains mergeable cells.
  127. //
  128. // In a table below:
  129. //
  130. // +---+---+---+---+
  131. // | a | b | c | d |
  132. // +---+---+---+ +
  133. // | e | f | |
  134. // + +---+---+
  135. // | | g | h |
  136. // +---+---+---+---+
  137. //
  138. // Valid selections are those which creates a solid rectangle (without gaps), such as:
  139. // - a, b (two horizontal cells)
  140. // - c, f (two vertical cells)
  141. // - a, b, e (cell "e" spans over four cells)
  142. // - c, d, f (cell d spans over cell in row below)
  143. //
  144. // While invalid selection would be:
  145. // - a, c (cell "b" not selected creates a gap)
  146. // - f, g, h (cell "d" spans over a cell from row of "f" cell - thus creates a gap)
  147. //
  148. // @param {module:engine/model/selection~Selection} selection
  149. // @param {module:table/tableUtils~TableUtils} tableUtils
  150. // @returns {boolean}
  151. function canMergeCells( selection, tableUtils ) {
  152. // Collapsed selection or selection only one range can't contain mergeable table cells.
  153. if ( selection.isCollapsed || selection.rangeCount < 2 ) {
  154. return false;
  155. }
  156. // All cells must be inside the same table.
  157. let firstRangeTable;
  158. for ( const range of selection.getRanges() ) {
  159. // Selection ranges must be set on whole <tableCell> element.
  160. if ( range.isCollapsed || !range.isFlat || !range.start.nodeAfter.is( 'tableCell' ) ) {
  161. return false;
  162. }
  163. const parentTable = findAncestor( 'table', range.start );
  164. if ( !firstRangeTable ) {
  165. firstRangeTable = parentTable;
  166. } else if ( firstRangeTable !== parentTable ) {
  167. return false;
  168. }
  169. }
  170. const selectedTableCells = getSelectionAffectedTableCells( selection );
  171. // At this point selection contains ranges over table cells in the same table.
  172. // The valid selection is a fully occupied rectangle composed of table cells.
  173. // Below we calculate area of selected cells and the area of valid selection.
  174. // The area of valid selection is defined by top-left and bottom-right cells.
  175. const rows = new Set();
  176. const columns = new Set();
  177. let areaOfSelectedCells = 0;
  178. for ( const tableCell of selectedTableCells ) {
  179. const { row, column } = tableUtils.getCellLocation( tableCell );
  180. const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
  181. const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
  182. // Record row & column indexes of current cell.
  183. rows.add( row );
  184. columns.add( column );
  185. // For cells that spans over multiple rows add also the last row that this cell spans over.
  186. if ( rowspan > 1 ) {
  187. rows.add( row + rowspan - 1 );
  188. }
  189. // For cells that spans over multiple columns add also the last column that this cell spans over.
  190. if ( colspan > 1 ) {
  191. columns.add( column + colspan - 1 );
  192. }
  193. areaOfSelectedCells += ( rowspan * colspan );
  194. }
  195. // We can only merge table cells that are in adjacent rows...
  196. const areaOfValidSelection = getBiggestRectangleArea( rows, columns );
  197. return areaOfValidSelection == areaOfSelectedCells;
  198. }
  199. // Calculates the area of a maximum rectangle that can span over provided row & column indexes.
  200. //
  201. // @param {Array.<Number>} rows
  202. // @param {Array.<Number>} columns
  203. // @returns {Number}
  204. function getBiggestRectangleArea( rows, columns ) {
  205. const rowsIndexes = Array.from( rows.values() );
  206. const columnIndexes = Array.from( columns.values() );
  207. const lastRow = Math.max( ...rowsIndexes );
  208. const firstRow = Math.min( ...rowsIndexes );
  209. const lastColumn = Math.max( ...columnIndexes );
  210. const firstColumn = Math.min( ...columnIndexes );
  211. return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 );
  212. }