mergecellscommand.js 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  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 { getRowIndexes, getSelectedTableCells } from '../utils';
  13. /**
  14. * The merge cells command.
  15. *
  16. * The command is registered by the {@link module:table/tableediting~TableEditing} as `'mergeTableCells'` editor command.
  17. *
  18. * For example, to merge selected table cells:
  19. *
  20. * editor.execute( 'mergeTableCells' );
  21. *
  22. * @extends module:core/command~Command
  23. */
  24. export default class MergeCellsCommand extends Command {
  25. /**
  26. * @inheritDoc
  27. */
  28. refresh() {
  29. this.isEnabled = canMergeCells( this.editor.model.document.selection, this.editor.plugins.get( TableUtils ) );
  30. }
  31. /**
  32. * Executes the command.
  33. *
  34. * @fires execute
  35. */
  36. execute() {
  37. const model = this.editor.model;
  38. const tableUtils = this.editor.plugins.get( TableUtils );
  39. model.change( writer => {
  40. const selectedTableCells = getSelectedTableCells( model.document.selection );
  41. // All cells will be merge into the first one.
  42. const firstTableCell = selectedTableCells.shift();
  43. // Update target cell dimensions.
  44. const { mergeWidth, mergeHeight } = getMergeDimensions( firstTableCell, selectedTableCells, tableUtils );
  45. updateNumericAttribute( 'colspan', mergeWidth, firstTableCell, writer );
  46. updateNumericAttribute( 'rowspan', mergeHeight, firstTableCell, writer );
  47. for ( const tableCell of selectedTableCells ) {
  48. const tableRow = tableCell.parent;
  49. mergeTableCells( tableCell, firstTableCell, writer );
  50. removeRowIfEmpty( tableRow, writer );
  51. }
  52. writer.setSelection( firstTableCell, 'in' );
  53. } );
  54. }
  55. }
  56. // Properly removes the empty row from a table. Updates the `rowspan` attribute of cells that overlap the removed row.
  57. //
  58. // @param {module:engine/model/element~Element} row
  59. // @param {module:engine/model/writer~Writer} writer
  60. function removeRowIfEmpty( row, writer ) {
  61. if ( row.childCount ) {
  62. return;
  63. }
  64. const table = row.parent;
  65. const removedRowIndex = table.getChildIndex( row );
  66. for ( const { cell, row, rowspan } of new TableWalker( table, { endRow: removedRowIndex } ) ) {
  67. const overlapsRemovedRow = row + rowspan - 1 >= removedRowIndex;
  68. if ( overlapsRemovedRow ) {
  69. updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer );
  70. }
  71. }
  72. writer.remove( row );
  73. }
  74. // Merges two table cells - will ensure that after merging cells with empty paragraphs the result table cell will only have one paragraph.
  75. // If one of the merged table cells is empty, the merged table cell will have contents of the non-empty table cell.
  76. // If both are empty, the merged table cell will have only one empty paragraph.
  77. //
  78. // @param {module:engine/model/element~Element} cellBeingMerged
  79. // @param {module:engine/model/element~Element} targetCell
  80. // @param {module:engine/model/writer~Writer} writer
  81. function mergeTableCells( cellBeingMerged, targetCell, writer ) {
  82. if ( !isEmpty( cellBeingMerged ) ) {
  83. if ( isEmpty( targetCell ) ) {
  84. writer.remove( writer.createRangeIn( targetCell ) );
  85. }
  86. writer.move( writer.createRangeIn( cellBeingMerged ), writer.createPositionAt( targetCell, 'end' ) );
  87. }
  88. // Remove merged table cell.
  89. writer.remove( cellBeingMerged );
  90. }
  91. // Checks if the passed table cell contains an empty paragraph.
  92. //
  93. // @param {module:engine/model/element~Element} tableCell
  94. // @returns {Boolean}
  95. function isEmpty( tableCell ) {
  96. return tableCell.childCount == 1 && tableCell.getChild( 0 ).is( 'paragraph' ) && tableCell.getChild( 0 ).isEmpty;
  97. }
  98. // Checks if the selection contains mergeable cells.
  99. //
  100. // In a table below:
  101. //
  102. // ┌───┬───┬───┬───┐
  103. // │ a │ b │ c │ d │
  104. // ├───┴───┼───┤ │
  105. // │ e │ f │ │
  106. // ├ ├───┼───┤
  107. // │ │ g │ h │
  108. // └───────┴───┴───┘
  109. //
  110. // Valid selections are these which create a solid rectangle (without gaps), such as:
  111. // - a, b (two horizontal cells)
  112. // - c, f (two vertical cells)
  113. // - a, b, e (cell "e" spans over four cells)
  114. // - c, d, f (cell d spans over a cell in the row below)
  115. //
  116. // While an invalid selection would be:
  117. // - a, c (cell "b" not selected creates a gap)
  118. // - f, g, h (cell "d" spans over a cell from row of "f" cell - thus creates a gap)
  119. //
  120. // @param {module:engine/model/selection~Selection} selection
  121. // @param {module:table/tableUtils~TableUtils} tableUtils
  122. // @returns {boolean}
  123. function canMergeCells( selection, tableUtils ) {
  124. const selectedTableCells = getSelectedTableCells( selection );
  125. if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) {
  126. return false;
  127. }
  128. // A valid selection is a fully occupied rectangle composed of table cells.
  129. // Below we will calculate the area of a selected table cells and the area of valid selection.
  130. // The area of a valid selection is defined by top-left and bottom-right cells.
  131. const rows = new Set();
  132. const columns = new Set();
  133. let areaOfSelectedCells = 0;
  134. for ( const tableCell of selectedTableCells ) {
  135. const { row, column } = tableUtils.getCellLocation( tableCell );
  136. const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
  137. const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
  138. // Record row & column indexes of current cell.
  139. rows.add( row );
  140. columns.add( column );
  141. // For cells that spans over multiple rows add also the last row that this cell spans over.
  142. if ( rowspan > 1 ) {
  143. rows.add( row + rowspan - 1 );
  144. }
  145. // For cells that spans over multiple columns add also the last column that this cell spans over.
  146. if ( colspan > 1 ) {
  147. columns.add( column + colspan - 1 );
  148. }
  149. areaOfSelectedCells += ( rowspan * colspan );
  150. }
  151. // We can only merge table cells that are in adjacent rows...
  152. const areaOfValidSelection = getBiggestRectangleArea( rows, columns );
  153. return areaOfValidSelection == areaOfSelectedCells;
  154. }
  155. // Calculates the area of a maximum rectangle that can span over provided row & column indexes.
  156. //
  157. // @param {Array.<Number>} rows
  158. // @param {Array.<Number>} columns
  159. // @returns {Number}
  160. function getBiggestRectangleArea( rows, columns ) {
  161. const rowsIndexes = Array.from( rows.values() );
  162. const columnIndexes = Array.from( columns.values() );
  163. const lastRow = Math.max( ...rowsIndexes );
  164. const firstRow = Math.min( ...rowsIndexes );
  165. const lastColumn = Math.max( ...columnIndexes );
  166. const firstColumn = Math.min( ...columnIndexes );
  167. return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 );
  168. }
  169. function areCellInTheSameTableSection( tableCells ) {
  170. const table = findAncestor( 'table', tableCells[ 0 ] );
  171. const rowIndexes = getRowIndexes( tableCells );
  172. const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );
  173. const firstCellIsInBody = rowIndexes.first > headingRows - 1;
  174. const lastCellIsInBody = rowIndexes.last > headingRows - 1;
  175. return firstCellIsInBody === lastCellIsInBody;
  176. }
  177. function getMergeDimensions( firstTableCell, selectedTableCells, tableUtils ) {
  178. let maxWidthOffset = 0;
  179. let maxHeightOffset = 0;
  180. for ( const tableCell of selectedTableCells ) {
  181. const { row, column } = tableUtils.getCellLocation( tableCell );
  182. maxWidthOffset = getMaxOffset( tableCell, column, maxWidthOffset, 'colspan' );
  183. maxHeightOffset = getMaxOffset( tableCell, row, maxHeightOffset, 'rowspan' );
  184. }
  185. // Update table cell span attribute and merge set selection on a merged contents.
  186. const { row: firstCellRow, column: firstCellColumn } = tableUtils.getCellLocation( firstTableCell );
  187. const mergeWidth = maxWidthOffset - firstCellColumn;
  188. const mergeHeight = maxHeightOffset - firstCellRow;
  189. return { mergeWidth, mergeHeight };
  190. }
  191. function getMaxOffset( tableCell, start, currentMaxOffset, which ) {
  192. const dimensionValue = parseInt( tableCell.getAttribute( which ) || 1 );
  193. return Math.max( currentMaxOffset, start + dimensionValue );
  194. }