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