diff --git a/packages/ckeditor5-table/src/commands/setheadercolumncommand.js b/packages/ckeditor5-table/src/commands/setheadercolumncommand.js index 69c9d1256ef..078fc199e67 100644 --- a/packages/ckeditor5-table/src/commands/setheadercolumncommand.js +++ b/packages/ckeditor5-table/src/commands/setheadercolumncommand.js @@ -10,7 +10,7 @@ import Command from '@ckeditor/ckeditor5-core/src/command'; import { findAncestor, isHeadingColumnCell, updateNumericAttribute } from './utils'; -import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils'; +import { getColumnIndexes, getSelectionAffectedTableCells, getHorizontallyOverlappingCells, splitVertically } from '../utils'; /** * The header column command. @@ -69,12 +69,21 @@ export default class SetHeaderColumnCommand extends Command { const model = this.editor.model; const selectedCells = getSelectionAffectedTableCells( model.document.selection ); - const { first, last } = getColumnIndexes( selectedCells ); + const table = findAncestor( 'table', selectedCells[ 0 ] ); + const { first, last } = getColumnIndexes( selectedCells ); const headingColumnsToSet = this.value ? first : last + 1; model.change( writer => { - const table = findAncestor( 'table', selectedCells[ 0 ] ); + if ( headingColumnsToSet ) { + // Changing heading columns requires to check if any of a heading cell is overlapping horizontally the table head. + // Any table cell that has a colspan attribute > 1 will not exceed the table head so we need to fix it in columns before. + const overlappingCells = getHorizontallyOverlappingCells( table, headingColumnsToSet ); + + for ( const { cell, column } of overlappingCells ) { + splitVertically( cell, column, headingColumnsToSet, writer ); + } + } updateNumericAttribute( 'headingColumns', headingColumnsToSet, table, writer, 0 ); } ); diff --git a/packages/ckeditor5-table/src/commands/setheaderrowcommand.js b/packages/ckeditor5-table/src/commands/setheaderrowcommand.js index a4fc96804bb..d6b6c256558 100644 --- a/packages/ckeditor5-table/src/commands/setheaderrowcommand.js +++ b/packages/ckeditor5-table/src/commands/setheaderrowcommand.js @@ -9,9 +9,8 @@ import Command from '@ckeditor/ckeditor5-core/src/command'; -import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './utils'; -import { getRowIndexes, getSelectionAffectedTableCells } from '../utils'; -import TableWalker from '../tablewalker'; +import { findAncestor, updateNumericAttribute } from './utils'; +import { getVerticallyOverlappingCells, getRowIndexes, getSelectionAffectedTableCells, splitHorizontally } from '../utils'; /** * The header row command. @@ -77,9 +76,10 @@ export default class SetHeaderRowCommand extends Command { if ( headingRowsToSet ) { // Changing heading rows requires to check if any of a heading cell is overlapping vertically the table head. // Any table cell that has a rowspan attribute > 1 will not exceed the table head so we need to fix it in rows below. - const cellsToSplit = getOverlappingCells( table, headingRowsToSet, currentHeadingRows ); + const startRow = headingRowsToSet > currentHeadingRows ? currentHeadingRows : 0; + const overlappingCells = getVerticallyOverlappingCells( table, headingRowsToSet, startRow ); - for ( const cell of cellsToSplit ) { + for ( const { cell } of overlappingCells ) { splitHorizontally( cell, headingRowsToSet, writer ); } } @@ -102,77 +102,3 @@ export default class SetHeaderRowCommand extends Command { return !!headingRows && tableCell.parent.index < headingRows; } } - -// Returns cells that span beyond the new heading section. -// -// @param {module:engine/model/element~Element} table The table to check. -// @param {Number} headingRowsToSet New heading rows attribute. -// @param {Number} currentHeadingRows Current heading rows attribute. -// @returns {Array.} -function getOverlappingCells( table, headingRowsToSet, currentHeadingRows ) { - const cellsToSplit = []; - - const startAnalysisRow = headingRowsToSet > currentHeadingRows ? currentHeadingRows : 0; - // We're analyzing only when headingRowsToSet > 0. - const endAnalysisRow = headingRowsToSet - 1; - - const tableWalker = new TableWalker( table, { startRow: startAnalysisRow, endRow: endAnalysisRow } ); - - for ( const { row, rowspan, cell } of tableWalker ) { - if ( rowspan > 1 && row + rowspan > headingRowsToSet ) { - cellsToSplit.push( cell ); - } - } - - return cellsToSplit; -} - -// Splits the table cell horizontally. -// -// @param {module:engine/model/element~Element} tableCell -// @param {Number} headingRows -// @param {module:engine/model/writer~Writer} writer -function splitHorizontally( tableCell, headingRows, writer ) { - const tableRow = tableCell.parent; - const table = tableRow.parent; - const rowIndex = tableRow.index; - - const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) ); - const newRowspan = headingRows - rowIndex; - - const attributes = {}; - - const spanToSet = rowspan - newRowspan; - - if ( spanToSet > 1 ) { - attributes.rowspan = spanToSet; - } - - const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); - - if ( colspan > 1 ) { - attributes.colspan = colspan; - } - - const startRow = table.getChildIndex( tableRow ); - const endRow = startRow + newRowspan; - const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeSpanned: true } ) ]; - - let columnIndex; - - for ( const { row, column, cell, cellIndex } of tableMap ) { - if ( cell === tableCell && columnIndex === undefined ) { - columnIndex = column; - } - - if ( columnIndex !== undefined && columnIndex === column && row === endRow ) { - const tableRow = table.getChild( row ); - const tableCellPosition = writer.createPositionAt( tableRow, cellIndex ); - - createEmptyTableCell( writer, tableCellPosition, attributes ); - } - } - - // Update the rowspan attribute after updating table. - updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer ); -} diff --git a/packages/ckeditor5-table/src/tableclipboard.js b/packages/ckeditor5-table/src/tableclipboard.js index f485243a75a..91170bfb9bb 100644 --- a/packages/ckeditor5-table/src/tableclipboard.js +++ b/packages/ckeditor5-table/src/tableclipboard.js @@ -11,7 +11,16 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import TableSelection from './tableselection'; import TableWalker from './tablewalker'; -import { getColumnIndexes, getRowIndexes, getSelectionAffectedTableCells, isSelectionRectangular } from './utils'; +import { + getColumnIndexes, + getVerticallyOverlappingCells, + getRowIndexes, + getSelectionAffectedTableCells, + getHorizontallyOverlappingCells, + isSelectionRectangular, + splitHorizontally, + splitVertically +} from './utils'; import { findAncestor } from './commands/utils'; import { cropTableToDimensions } from './tableselection/croptable'; import TableUtils from './tableutils'; @@ -110,9 +119,6 @@ export default class TableClipboard extends Plugin { return; } - // Content table to which we insert a pasted table. - const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] ); - // We might need to crop table before inserting so reference might change. let pastedTable = getTableIfOnlyTableInContent( content ); @@ -123,32 +129,70 @@ export default class TableClipboard extends Plugin { // Override default model.insertContent() handling at this point. evt.stop(); - const pasteWidth = tableUtils.getColumns( pastedTable ); - const pasteHeight = tableUtils.getRows( pastedTable ); - model.change( writer => { - let { first: firstColumnOfSelection, last: lastColumnOfSelection } = getColumnIndexes( selectedTableCells ); - let { first: firstRowOfSelection, last: lastRowOfSelection } = getRowIndexes( selectedTableCells ); + const columnIndexes = getColumnIndexes( selectedTableCells ); + const rowIndexes = getRowIndexes( selectedTableCells ); + + let { first: firstColumnOfSelection, last: lastColumnOfSelection } = columnIndexes; + let { first: firstRowOfSelection, last: lastRowOfSelection } = rowIndexes; + + const pasteHeight = tableUtils.getRows( pastedTable ); + const pasteWidth = tableUtils.getColumns( pastedTable ); - if ( selectedTableCells.length == 1 ) { + // Content table to which we insert a pasted table. + const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] ); + + // Single cell selected - expand selection to pasted table dimensions. + const shouldExpandSelection = selectedTableCells.length === 1; + + if ( shouldExpandSelection ) { lastRowOfSelection += pasteHeight - 1; lastColumnOfSelection += pasteWidth - 1; expandTableSize( selectedTable, lastRowOfSelection + 1, lastColumnOfSelection + 1, writer, tableUtils ); } - // Currently not handled. The selected table content should be trimmed to a rectangular selection. - // See: https://github.com/ckeditor/ckeditor5/issues/6122. - else if ( !isSelectionRectangular( selectedTableCells, tableUtils ) ) { - // @if CK_DEBUG // console.log( 'NOT IMPLEMENTED YET: Selection is not rectangular (non-mergeable).' ); + // In case of expanding selection we do not reset the selection so in this case we will always try to fix selection + // like in the case of a non-rectangular area. This might be fixed by re-setting selected cells array but this shortcut is safe. + if ( shouldExpandSelection || !isSelectionRectangular( selectedTableCells, tableUtils ) ) { + const splitDimensions = { + firstRow: firstRowOfSelection, + lastRow: lastRowOfSelection, + firstColumn: firstColumnOfSelection, + lastColumn: lastColumnOfSelection + }; - return; + // For a non-rectangular selection (ie in which some cells sticks out from a virtual selection rectangle) we need to create + // a table layout that has a rectangular selection. This will split cells so the selection become rectangular. + // Beyond this point we will operate on fixed content table. + splitCellsToRectangularSelection( selectedTable, splitDimensions, writer ); + } + // However a selected table fragment might be invalid if examined alone. Ie such table fragment: + // + // +---+---+---+---+ + // 0 | a | b | c | d | + // + + +---+---+ + // 1 | | e | f | g | + // + +---+ +---+ + // 2 | | h | | i | <- last row, each cell has rowspan = 2, + // + + + + + so we need to return 3, not 2 + // 3 | | | | | + // +---+---+---+---+ + // + // is invalid as the cells "h" and "i" have rowspans. + // This case needs only adjusting the selection dimension as the rest of the algorithm operates on empty slots also. + else { + lastRowOfSelection = adjustLastRowIndex( selectedTable, rowIndexes, columnIndexes ); + lastColumnOfSelection = adjustLastColumnIndex( selectedTable, rowIndexes, columnIndexes ); } + // Beyond this point we operate on a fixed content table with rectangular selection and proper last row/column values. + const selectionHeight = lastRowOfSelection - firstRowOfSelection + 1; const selectionWidth = lastColumnOfSelection - firstColumnOfSelection + 1; // The if below is temporal and will be removed when handling this case. + // This if to be removed as handling of replicating cells should be done in replaceSelectedCellsWithPasted(). // See: https://github.com/ckeditor/ckeditor5/issues/6769. if ( selectionHeight > pasteHeight || selectionWidth > pasteWidth ) { // @if CK_DEBUG // console.log( 'NOT IMPLEMENTED YET: Pasted table is smaller than selection area.' ); @@ -156,17 +200,21 @@ export default class TableClipboard extends Plugin { return; } - // Crop pasted table if it extends selection area. - if ( selectionHeight < pasteHeight || selectionWidth < pasteWidth ) { - const cropDimensions = { - startRow: 0, - startColumn: 0, - endRow: selectionHeight - 1, - endColumn: selectionWidth - 1 - }; + // Crop pasted table if: + // - Pasted table dimensions exceeds selection area. + // - Pasted table has broken layout (ie some cells sticks out by the table dimensions established by the first and last row). + // + // Note: The table dimensions are established by the width of the first row and the total number of rows. + // It is possible to programmatically create a table that has rows which would have cells anchored beyond first row width but + // such table will not be created by other editing solutions. + const cropDimensions = { + startRow: 0, + startColumn: 0, + endRow: Math.min( selectionHeight - 1, pasteHeight - 1 ), + endColumn: Math.min( selectionWidth - 1, pasteWidth - 1 ) + }; - pastedTable = cropTableToDimensions( pastedTable, cropDimensions, writer, tableUtils ); - } + pastedTable = cropTableToDimensions( pastedTable, cropDimensions, writer, tableUtils ); const selectionDimensions = { firstColumnOfSelection, @@ -196,8 +244,9 @@ export default class TableClipboard extends Plugin { // @param {module:engine/model/writer~Writer} writer function replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDimensions, writer ) { const { - firstColumnOfSelection, lastColumnOfSelection, selectionWidth, - firstRowOfSelection, lastRowOfSelection, selectionHeight + firstColumnOfSelection, lastColumnOfSelection, + firstRowOfSelection, lastRowOfSelection, + selectionWidth, selectionHeight } = selectionDimensions; // Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location. @@ -272,24 +321,24 @@ function replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDi writer.setSelection( cellsToSelect.map( cell => writer.createRangeOn( cell ) ) ); } -// Expand table (in place) to expected size (rows and columns). -function expandTableSize( table, rows, columns, writer, tableUtils ) { +// Expand table (in place) to expected size. +function expandTableSize( table, expectedHeight, expectedWidth, writer, tableUtils ) { const tableWidth = tableUtils.getColumns( table ); const tableHeight = tableUtils.getRows( table ); - if ( columns > tableWidth ) { + if ( expectedWidth > tableWidth ) { tableUtils.insertColumns( table, { batch: writer.batch, at: tableWidth, - columns: columns - tableWidth + columns: expectedWidth - tableWidth } ); } - if ( rows > tableHeight ) { + if ( expectedHeight > tableHeight ) { tableUtils.insertRows( table, { batch: writer.batch, at: tableHeight, - rows: rows - tableHeight + rows: expectedHeight - tableHeight } ); } } @@ -349,3 +398,173 @@ function createLocationMap( table, width, height ) { return map; } + +// Make selected cells rectangular by splitting the cells that stand out from a rectangular selection. +// +// In the table below a selection is shown with "::" and slots with anchor cells are named. +// +// +----+----+----+----+----+ +----+----+----+----+----+ +// | 00 | 01 | 02 | 03 | | 00 | 01 | 02 | 03 | +// + +----+ +----+----+ | ::::::::::::::::----+ +// | | 11 | | 13 | 14 | | ::11 | | 13:: 14 | <- first row +// +----+----+ + +----+ +----::---| | ::----+ +// | 20 | 21 | | | 24 | select cells: | 20 ::21 | | :: 24 | +// +----+----+ +----+----+ 11 -> 33 +----::---| |---::----+ +// | 30 | | 33 | 34 | | 30 :: | | 33:: 34 | <- last row +// + + +----+ + | :::::::::::::::: + +// | | | 43 | | | | | 43 | | +// +----+----+----+----+----+ +----+----+----+----+----+ +// ^ ^ +// first & last columns +// +// Will update table to: +// +// +----+----+----+----+----+ +// | 00 | 01 | 02 | 03 | +// + +----+----+----+----+ +// | | 11 | | 13 | 14 | +// +----+----+ + +----+ +// | 20 | 21 | | | 24 | +// +----+----+ +----+----+ +// | 30 | | | 33 | 34 | +// + +----+----+----+ + +// | | | | 43 | | +// +----+----+----+----+----+ +// +// In th example above: +// - Cell "02" which have `rowspan = 4` must be trimmed at first and at after last row. +// - Cell "03" which have `rowspan = 2` and `colspan = 2` must be trimmed at first column and after last row. +// - Cells "00", "03" & "30" which cannot be cut by this algorithm as they are outside the trimmed area. +// - Cell "13" cannot be cut as it is inside the trimmed area. +function splitCellsToRectangularSelection( table, dimensions, writer ) { + const { firstRow, lastRow, firstColumn, lastColumn } = dimensions; + + const rowIndexes = { first: firstRow, last: lastRow }; + const columnIndexes = { first: firstColumn, last: lastColumn }; + + // 1. Split cells vertically in two steps as first step might create cells that needs to split again. + doVerticalSplit( table, firstColumn, rowIndexes, writer ); + doVerticalSplit( table, lastColumn + 1, rowIndexes, writer ); + + // 2. Split cells horizontally in two steps as first step might create cells that needs to split again. + doHorizontalSplit( table, firstRow, columnIndexes, writer ); + doHorizontalSplit( table, lastRow + 1, columnIndexes, writer, firstRow ); +} + +function doHorizontalSplit( table, splitRow, limitColumns, writer, startRow = 0 ) { + // If selection starts at first row then no split is needed. + if ( splitRow < 1 ) { + return; + } + + const overlappingCells = getVerticallyOverlappingCells( table, splitRow, startRow ); + + // Filter out cells that are not touching insides of the rectangular selection. + const cellsToSplit = overlappingCells.filter( ( { column, colspan } ) => isAffectedBySelection( column, colspan, limitColumns ) ); + + for ( const { cell } of cellsToSplit ) { + splitHorizontally( cell, splitRow, writer ); + } +} + +function doVerticalSplit( table, splitColumn, limitRows, writer ) { + // If selection starts at first column then no split is needed. + if ( splitColumn < 1 ) { + return; + } + + const overlappingCells = getHorizontallyOverlappingCells( table, splitColumn ); + + // Filter out cells that are not touching insides of the rectangular selection. + const cellsToSplit = overlappingCells.filter( ( { row, rowspan } ) => isAffectedBySelection( row, rowspan, limitRows ) ); + + for ( const { cell, column } of cellsToSplit ) { + splitVertically( cell, column, splitColumn, writer ); + } +} + +// Checks if cell at given row (column) is affected by a rectangular selection defined by first/last column (row). +// +// The same check is used for row as for column. +function isAffectedBySelection( index, span, limit ) { + const endIndex = index + span - 1; + const { first, last } = limit; + + const isInsideSelection = index >= first && index <= last; + const overlapsSelectionFromOutside = index < first && endIndex >= first; + + return isInsideSelection || overlapsSelectionFromOutside; +} + +// Returns adjusted last row index if selection covers part of a row with empty slots (spanned by other cells). +// The rowIndexes.last is equal to last row index but selection might be bigger. +// +// This happens *only* on rectangular selection so we analyze a case like this: +// +// +---+---+---+---+ +// 0 | a | b | c | d | +// + + +---+---+ +// 1 | | e | f | g | +// + +---+ +---+ +// 2 | | h | | i | <- last row, each cell has rowspan = 2, +// + + + + + so we need to return 3, not 2 +// 3 | | | | | +// +---+---+---+---+ +function adjustLastRowIndex( table, rowIndexes, columnIndexes ) { + const tableIterator = new TableWalker( table, { + startRow: rowIndexes.last, + endRow: rowIndexes.last + } ); + + const lastRowMap = Array.from( tableIterator ).filter( ( { column } ) => { + // Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785. + return columnIndexes.first <= column && column <= columnIndexes.last; + } ); + + const everyCellHasSingleRowspan = lastRowMap.every( ( { rowspan } ) => rowspan === 1 ); + + // It is a "flat" row, so the last row index is OK. + if ( everyCellHasSingleRowspan ) { + return rowIndexes.last; + } + + // Otherwise get any cell's rowspan and adjust the last row index. + const rowspanAdjustment = lastRowMap[ 0 ].rowspan - 1; + return rowIndexes.last + rowspanAdjustment; +} + +// Returns adjusted last column index if selection covers part of a column with empty slots (spanned by other cells). +// The columnIndexes.last is equal to last column index but selection might be bigger. +// +// This happens *only* on rectangular selection so we analyze a case like this: +// +// 0 1 2 3 +// +---+---+---+---+ +// | a | +// +---+---+---+---+ +// | b | c | d | +// +---+---+---+---+ +// | e | f | +// +---+---+---+---+ +// | g | h | +// +---+---+---+---+ +// ^ +// last column, each cell has colspan = 2, so we need to return 3, not 2 +function adjustLastColumnIndex( table, rowIndexes, columnIndexes ) { + const lastColumnMap = Array.from( new TableWalker( table, { + startRow: rowIndexes.first, + endRow: rowIndexes.last, + column: columnIndexes.last + } ) ); + + const everyCellHasSingleColspan = lastColumnMap.every( ( { colspan } ) => colspan === 1 ); + + // It is a "flat" column, so the last column index is OK. + if ( everyCellHasSingleColspan ) { + return columnIndexes.last; + } + + // Otherwise get any cell's colspan and adjust the last column index. + const colspanAdjustment = lastColumnMap[ 0 ].colspan - 1; + return columnIndexes.last + colspanAdjustment; +} diff --git a/packages/ckeditor5-table/src/utils.js b/packages/ckeditor5-table/src/utils.js index d6cdae0e439..f4d9f39eefd 100644 --- a/packages/ckeditor5-table/src/utils.js +++ b/packages/ckeditor5-table/src/utils.js @@ -8,7 +8,7 @@ */ import { isWidget, toWidget } from '@ckeditor/ckeditor5-widget/src/utils'; -import { findAncestor } from './commands/utils'; +import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './commands/utils'; import TableWalker from './tablewalker'; /** @@ -188,7 +188,7 @@ export function getColumnIndexes( tableCells ) { * │ a │ b │ c │ d │ * ├───┴───┼───┤ │ * │ e │ f │ │ - * ├ ├───┼───┤ + * │ ├───┼───┤ * │ │ g │ h │ * └───────┴───┴───┘ * @@ -247,6 +247,171 @@ export function isSelectionRectangular( selectedTableCells, tableUtils ) { return areaOfValidSelection == areaOfSelectedCells; } +/** + * Returns slot info of cells that starts above and overlaps a given row. + * + * In a table below, passing `overlapRow = 3` + * + * ┌───┬───┬───┬───┬───┐ + * 0 │ a │ b │ c │ d │ e │ + * │ ├───┼───┼───┼───┤ + * 1 │ │ f │ g │ h │ i │ + * ├───┤ ├───┼───┤ │ + * 2 │ j │ │ k │ l │ │ + * │ │ │ ├───┼───┤ + * 3 │ │ │ │ m │ n │ <- overlap row to check + * ├───┼───┤ │ ├───│ + * 4 │ o │ p │ │ │ q │ + * └───┴───┴───┴───┴───┘ + * + * will return slot info for cells: "j", "f", "k". + * + * @param {module:engine/model/element~Element} table The table to check. + * @param {Number} overlapRow The index of the row to check. + * @param {Number} [startRow=0] A row to start analysis. Use it when it is known that the cells above that row will not overlap. + * @returns {Array.} + */ +export function getVerticallyOverlappingCells( table, overlapRow, startRow = 0 ) { + const cells = []; + + const tableWalker = new TableWalker( table, { startRow, endRow: overlapRow - 1 } ); + + for ( const slotInfo of tableWalker ) { + const { row, rowspan } = slotInfo; + const cellEndRow = row + rowspan - 1; + + if ( row < overlapRow && overlapRow <= cellEndRow ) { + cells.push( slotInfo ); + } + } + + return cells; +} + +/** + * Splits the table cell horizontally. + * + * @param {module:engine/model/element~Element} tableCell + * @param {Number} splitRow + * @param {module:engine/model/writer~Writer} writer + */ +export function splitHorizontally( tableCell, splitRow, writer ) { + const tableRow = tableCell.parent; + const table = tableRow.parent; + const rowIndex = tableRow.index; + + const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) ); + const newRowspan = splitRow - rowIndex; + + const newCellAttributes = {}; + const newCellRowSpan = rowspan - newRowspan; + + if ( newCellRowSpan > 1 ) { + newCellAttributes.rowspan = newCellRowSpan; + } + + const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); + + if ( colspan > 1 ) { + newCellAttributes.colspan = colspan; + } + + const startRow = rowIndex; + const endRow = startRow + newRowspan; + const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeSpanned: true } ) ]; + + let columnIndex; + + for ( const { row, column, cell, cellIndex } of tableMap ) { + if ( cell === tableCell && columnIndex === undefined ) { + columnIndex = column; + } + + if ( columnIndex !== undefined && columnIndex === column && row === endRow ) { + const tableRow = table.getChild( row ); + const tableCellPosition = writer.createPositionAt( tableRow, cellIndex ); + + createEmptyTableCell( writer, tableCellPosition, newCellAttributes ); + } + } + + // Update the rowspan attribute after updating table. + updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer ); +} + +/** + * Returns slot info of cells that starts before and overlaps a given column. + * + * In a table below, passing `overlapColumn = 3` + * + * 0 1 2 3 4 + * ┌───────┬───────┬───┐ + * │ a │ b │ c │ + * │───┬───┴───────┼───┤ + * │ d │ e │ f │ + * ├───┼───┬───────┴───┤ + * │ g │ h │ i │ + * ├───┼───┼───┬───────┤ + * │ j │ k │ l │ m │ + * ├───┼───┴───┼───┬───┤ + * │ n │ o │ p │ q │ + * └───┴───────┴───┴───┘ + * ^ + * Overlap column to check + * + * will return slot info for cells: "b", "e", "i". + * + * @param {module:engine/model/element~Element} table The table to check. + * @param {Number} overlapColumn The index of the column to check. + * @returns {Array.} + */ +export function getHorizontallyOverlappingCells( table, overlapColumn ) { + const cellsToSplit = []; + + const tableWalker = new TableWalker( table ); + + for ( const slotInfo of tableWalker ) { + const { column, colspan } = slotInfo; + const cellEndColumn = column + colspan - 1; + + if ( column < overlapColumn && overlapColumn <= cellEndColumn ) { + cellsToSplit.push( slotInfo ); + } + } + + return cellsToSplit; +} + +/** + * Splits the table cell vertically. + * + * @param {module:engine/model/element~Element} tableCell + * @param {Number} columnIndex The table cell column index. + * @param {Number} splitColumn The index of column to split cell on. + * @param {module:engine/model/writer~Writer} writer + */ +export function splitVertically( tableCell, columnIndex, splitColumn, writer ) { + const colspan = parseInt( tableCell.getAttribute( 'colspan' ) ); + const newColspan = splitColumn - columnIndex; + + const newCellAttributes = {}; + const newCellColSpan = colspan - newColspan; + + if ( newCellColSpan > 1 ) { + newCellAttributes.colspan = newCellColSpan; + } + + const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 ); + + if ( rowspan > 1 ) { + newCellAttributes.rowspan = rowspan; + } + + createEmptyTableCell( writer, writer.createPositionAfter( tableCell ), newCellAttributes ); + // Update the colspan attribute after updating table. + updateNumericAttribute( 'colspan', newColspan, tableCell, writer ); +} + // Helper method to get an object with `first` and `last` indexes from an unsorted array of indexes. function getFirstLastIndexesObject( indexes ) { const allIndexesSorted = indexes.sort( ( indexA, indexB ) => indexA - indexB ); diff --git a/packages/ckeditor5-table/tests/commands/setheadercolumncommand.js b/packages/ckeditor5-table/tests/commands/setheadercolumncommand.js index e87d5c36cda..3d0904663b0 100644 --- a/packages/ckeditor5-table/tests/commands/setheadercolumncommand.js +++ b/packages/ckeditor5-table/tests/commands/setheadercolumncommand.js @@ -198,6 +198,18 @@ describe( 'SetHeaderColumnCommand', () => { ], { headingColumns: 1 } ) ); } ); + it( 'should remove "headingColumns" attribute from table if no value was given', () => { + setData( model, modelTable( [ + [ '[]00', '01', '02', '03' ] + ], { headingColumns: 3 } ) ); + + command.execute(); + + assertEqualMarkup( getData( model ), modelTable( [ + [ '[]00', '01', '02', '03' ] + ] ) ); + } ); + describe( 'multi-cell selection', () => { describe( 'setting header', () => { it( 'should set it correctly in a middle of single-row, multiple cell selection', () => { @@ -404,7 +416,7 @@ describe( 'SetHeaderColumnCommand', () => { ], { headingColumns: 2 } ) ); } ); - it( 'should respect forceValue parameter #1', () => { + it( 'should respect forceValue parameter (forceValue=true)', () => { setData( model, modelTable( [ [ '00', '01[]', '02', '03' ] ], { headingColumns: 3 } ) ); @@ -416,7 +428,7 @@ describe( 'SetHeaderColumnCommand', () => { ], { headingColumns: 3 } ) ); } ); - it( 'should respect forceValue parameter #2', () => { + it( 'should respect forceValue parameter (forceValue=false)', () => { setData( model, modelTable( [ [ '00', '01[]', '02', '03' ] ], { headingColumns: 1 } ) ); @@ -427,5 +439,95 @@ describe( 'SetHeaderColumnCommand', () => { [ '00', '01[]', '02', '03' ] ], { headingColumns: 1 } ) ); } ); + + it( 'should fix col-spanned cells on the edge of an table heading columns section', () => { + // +----+----+----+ + // | 00 | 01 | + // +----+ + + // | 10 | | + // +----+----+----+ + // | 20 | 21 | 22 | + // +----+----+----+ + // ^-- heading columns + setData( model, modelTable( [ + [ '00', { contents: '[]01', colspan: 2, rowspan: 2 } ], + [ '10' ], + [ '20', '21', '22' ] + ], { headingColumns: 1 } ) ); + + command.execute(); + + // +----+----+----+ + // | 00 | 01 | | + // +----+ + + + // | 10 | | | + // +----+----+----+ + // | 20 | 21 | 22 | + // +----+----+----+ + // ^-- heading columns + assertEqualMarkup( getData( model ), modelTable( [ + [ '00', { contents: '[]01', rowspan: 2 }, { contents: '', rowspan: 2 } ], + [ '10' ], + [ '20', '21', '22' ] + ], { headingColumns: 2 } ) ); + } ); + + it( 'should split to at most 2 table cells when fixing col-spanned cells on the edge of an table heading columns section', () => { + // +----+----+----+----+----+----+ + // | 00 | 01 | + // +----+ + + // | 10 | | + // +----+----+----+----+----+----+ + // | 20 | 21 | 22 | 23 | 24 | 25 | + // +----+----+----+----+----+----+ + // ^-- heading columns + setData( model, modelTable( [ + [ '00', { contents: '01', colspan: 5, rowspan: 2 } ], + [ '10' ], + [ '20', '21', '22[]', '23', '24', '25' ] + ], { headingColumns: 1 } ) ); + + command.execute(); + + // +----+----+----+----+----+----+ + // | 00 | 01 | | + // +----+ + + + // | 10 | | | + // +----+----+----+----+----+----+ + // | 20 | 21 | 22 | 23 | 24 | 25 | + // +----+----+----+----+----+----+ + // ^-- heading columns + assertEqualMarkup( getData( model ), modelTable( [ + [ '00', { contents: '01', colspan: 2, rowspan: 2 }, { contents: '', colspan: 3, rowspan: 2 } ], + [ '10' ], + [ '20', '21', '22[]', '23', '24', '25' ] + ], { headingColumns: 3 } ) ); + } ); + + it( 'should fix col-spanned cells on the edge of an table heading columns section when creating section', () => { + // +----+----+ + // | 00 | + // +----+----+ + // | 10 | 11 | + // +----+----+ + // ^-- heading columns + setData( model, modelTable( [ + [ { contents: '00', colspan: 2 } ], + [ '10', '[]11' ] + ], { headingColumns: 2 } ) ); + + command.execute(); + + // +----+----+ + // | 00 | | + // +----+----+ + // | 10 | 11 | + // +----+----+ + // ^-- heading columns + assertEqualMarkup( getData( model ), modelTable( [ + [ '00', '' ], + [ '10', '[]11' ] + ], { headingColumns: 1 } ) ); + } ); } ); } ); diff --git a/packages/ckeditor5-table/tests/commands/setheaderrowcommand.js b/packages/ckeditor5-table/tests/commands/setheaderrowcommand.js index 67abee2d143..85e53937d77 100644 --- a/packages/ckeditor5-table/tests/commands/setheaderrowcommand.js +++ b/packages/ckeditor5-table/tests/commands/setheaderrowcommand.js @@ -225,7 +225,7 @@ describe( 'SetHeaderRowCommand', () => { ], { headingRows: 1 } ) ); } ); - it( 'should unsetset heading rows attribute', () => { + it( 'should remove "headingRows" attribute from table if no value was given', () => { setData( model, modelTable( [ [ '[]00' ], [ '10' ], @@ -551,7 +551,7 @@ describe( 'SetHeaderRowCommand', () => { } ); } ); - it( 'should respect forceValue parameter #1', () => { + it( 'should respect forceValue parameter (forceValue=true)', () => { setData( model, modelTable( [ [ '00' ], [ '[]10' ], @@ -569,7 +569,7 @@ describe( 'SetHeaderRowCommand', () => { ], { headingRows: 3 } ) ); } ); - it( 'should respect forceValue parameter #2', () => { + it( 'should respect forceValue parameter (forceValue=false)', () => { setData( model, modelTable( [ [ '00' ], [ '[]10' ], diff --git a/packages/ckeditor5-table/tests/tableclipboard-paste.js b/packages/ckeditor5-table/tests/tableclipboard-paste.js index d68ecdfb325..edd54218310 100644 --- a/packages/ckeditor5-table/tests/tableclipboard-paste.js +++ b/packages/ckeditor5-table/tests/tableclipboard-paste.js @@ -272,33 +272,6 @@ describe( 'table clipboard', () => { ] ) ); } ); - it( 'should block non-rectangular selection', () => { - setModelData( model, modelTable( [ - [ { contents: '00', colspan: 3 } ], - [ '10', '11', '12' ], - [ '20', '21', '22' ] - ] ) ); - - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 0, 0 ] ), - modelRoot.getNodeByPath( [ 0, 1, 1 ] ) - ); - - // Catches the temporary console log in the CK_DEBUG mode. - sinon.stub( console, 'log' ); - - pasteTable( [ - [ 'aa', 'ab' ], - [ 'ba', 'bb' ] - ] ); - - assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ - [ { contents: '00', colspan: 3 } ], - [ '10', '11', '12' ], - [ '20', '21', '22' ] - ] ) ); - } ); - describe( 'single cell selected', () => { beforeEach( () => { setModelData( model, modelTable( [ @@ -792,6 +765,39 @@ describe( 'table clipboard', () => { [ '', '', '', 'ea', 'eb', 'ec', 'ed' ] ] ) ); } ); + + it( 'should fix non-rectangular are on matched table fragment', () => { + // +----+----+----+ + // | 00 | 01 | 02 | + // +----+----+ + + // | 10 | 11 | | + // +----+----+----+ + // | 20 | 22 | + // +----+----+----+ + setModelData( model, modelTable( [ + [ '00', '01', { contents: '02', rowspan: 2 } ], + [ '10', '11[]' ], + [ { contents: '20', colspan: 2 }, '22' ] + ] ) ); + + pasteTable( [ + [ 'aa', 'ab' ], + [ 'ba', 'bb' ] + ] ); + + // +----+----+----+ + // | 00 | 01 | 02 | + // +----+----+----+ + // | 10 | aa | ab | + // +----+----+----+ + // | 20 | ba | bb | + // +----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02' ], + [ '10', 'aa', 'ab' ], + [ '20', 'ba', 'bb' ] + ] ) ); + } ); } ); } ); @@ -1371,8 +1377,7 @@ describe( 'table clipboard', () => { /* eslint-enable no-multi-spaces */ } ); - // TODO: Skipped case - should allow pasting but no tools to compare areas (like in MergeCellsCommand). - it.skip( 'handles pasting table that has cell with colspan (last row in selection is spanned)', () => { + it( 'handles pasting table that has cell with colspan (last row in selection is spanned)', () => { // +----+----+----+----+ // | 00 | 01 | 02 | 03 | // +----+----+----+----+ @@ -1389,6 +1394,7 @@ describe( 'table clipboard', () => { [ '30', '31', '32', '33' ] ] ) ); + // Select 02 -> 10 (selection 3x3) tableSelection.setCellSelection( modelRoot.getNodeByPath( [ 0, 0, 2 ] ), modelRoot.getNodeByPath( [ 0, 1, 0 ] ) @@ -1414,6 +1420,132 @@ describe( 'table clipboard', () => { [ 0, 0, 0, 0 ] ] ); } ); + + it( 'handles pasting table that has cell with colspan (multiple ending rows in the selection are spanned)', () => { + // +----+----+----+ + // | 00 | 01 | 02 | + // +----+ + + + // | 10 | | | + // +----+ + + + // | 20 | | | + // +----+----+----+ + // | 30 | 31 | 32 | + // +----+----+----+ + setModelData( model, modelTable( [ + [ '00', { contents: '01', rowspan: 3 }, { contents: '02', rowspan: 3 } ], + [ '10' ], + [ '20' ], + [ '30', '31', '32' ] + ] ) ); + + // Select 01 -> 02 (selection 2x2) + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 1 ] ), + modelRoot.getNodeByPath( [ 0, 0, 2 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab' ], + [ 'ba', 'bb' ], + [ 'ca', 'cb' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', 'aa', 'ab' ], + [ '10', 'ba', 'bb' ], + [ '20', 'ca', 'cb' ], + [ '30', '31', '32' ] + ] ) ); + + assertSelectedCells( model, [ + [ 0, 1, 1 ], + [ 0, 1, 1 ], + [ 0, 1, 1 ], + [ 0, 0, 0 ] + ] ); + } ); + + it( 'handles pasting table that has cell with colspan (last column in selection is spanned)', () => { + // +----+----+----+----+ + // | 00 | 01 | 03 | + // +----+----+----+----+ + // | 10 | 11 | 13 | + // +----+ +----+ + // | 20 | | 23 | + // +----+----+----+----+ + // | 30 | 31 | 32 | 33 | + // +----+----+----+----+ + setModelData( model, modelTable( [ + [ '00', { contents: '01', colspan: 2 }, '03' ], + [ '10', { contents: '11', colspan: 2, rowspan: 2 }, '13' ], + [ '20', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + // Select 20 -> 01 (selection 3x3) + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 2, 0 ] ), + modelRoot.getNodeByPath( [ 0, 0, 1 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab', 'ac' ], + [ 'ba', 'bb', 'bc' ], + [ 'ca', 'cb', 'cc' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', 'ab', 'ac', '03' ], + [ 'ba', 'bb', 'bc', '13' ], + [ 'ca', 'cb', 'cc', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + assertSelectedCells( model, [ + [ 1, 1, 1, 0 ], + [ 1, 1, 1, 0 ], + [ 1, 1, 1, 0 ], + [ 0, 0, 0, 0 ] + ] ); + } ); + + it( 'handles pasting table that has cell with colspan (multiple ending columns in the selection are spanned)', () => { + // +----+----+----+----+ + // | 00 | 01 | 02 | 03 | + // +----+----+----+----+ + // | 10 | 13 | + // +----+----+----+----+ + // | 20 | 23 | + // +----+----+----+----+ + setModelData( model, modelTable( [ + [ '00', '01', '02', '03' ], + [ { contents: '10', colspan: 3 }, '13' ], + [ { contents: '20', colspan: 3 }, '23' ] + ] ) ); + + // Select 10 -> 20 (selection 3x2) + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 0 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab', 'ac' ], + [ 'ba', 'bb', 'bc' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03' ], + [ 'aa', 'ab', 'ac', '13' ], + [ 'ba', 'bb', 'bc', '23' ] + ] ) ); + + assertSelectedCells( model, [ + [ 0, 0, 0, 0 ], + [ 1, 1, 1, 0 ], + [ 1, 1, 1, 0 ] + ] ); + } ); } ); describe( 'content and paste tables have spans', () => { @@ -1565,8 +1697,7 @@ describe( 'table clipboard', () => { /* eslint-enable no-multi-spaces */ } ); - // TODO: Skipped case - should allow pasting but no tools to compare areas (like in MergeCellsCommand). - it.skip( 'handles pasting table that has cell with colspan (last row in selection is spanned)', () => { + it( 'handles pasting table that has cell with colspan (last row in selection is spanned)', () => { // +----+----+----+----+ // | 00 | 01 | 02 | 03 | // +----+----+----+----+ @@ -1583,6 +1714,7 @@ describe( 'table clipboard', () => { [ '30', '31', '32', '33' ] ] ) ); + // Select 02 -> 10 (selection 3x3) tableSelection.setCellSelection( modelRoot.getNodeByPath( [ 0, 0, 2 ] ), modelRoot.getNodeByPath( [ 0, 1, 0 ] ) @@ -1602,111 +1734,591 @@ describe( 'table clipboard', () => { ] ); assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ - [ '00', '01', '02', '03' ], - [ '10', { colspan: 2, contents: 'aa' }, '13' ], - [ '20', 'ba', 'bb', '23' ], + [ 'aa', 'ab', 'ac', '03' ], + [ { contents: 'ba', colspan: 2, rowspan: 2 }, 'bc', '13' ], + [ 'cc', '23' ], [ '30', '31', '32', '33' ] ] ) ); /* eslint-disable no-multi-spaces */ assertSelectedCells( model, [ - [ 0, 0, 0, 0 ], - [ 0, 1, 0 ], - [ 0, 1, 1, 0 ], + [ 1, 1, 1, 0 ], + [ 1, 1, 0 ], + [ 1, 0 ], [ 0, 0, 0, 0 ] ] ); /* eslint-enable no-multi-spaces */ } ); - } ); - } ); - describe( 'pasted table is bigger than the selected area', () => { - describe( 'no spans', () => { - it( 'handles simple table paste to a simple table fragment - at the beginning of a table', () => { + it( 'handles pasting table that has cell with colspan (last column in selection is spanned)', () => { + // +----+----+----+----+ + // | 00 | 01 | 03 | + // +----+----+----+----+ + // | 10 | 11 | 13 | + // +----+ +----+ + // | 20 | | 23 | + // +----+----+----+----+ + // | 30 | 31 | 32 | 33 | + // +----+----+----+----+ + setModelData( model, modelTable( [ + [ '00', { contents: '01', colspan: 2 }, '03' ], + [ '10', { contents: '11', colspan: 2, rowspan: 2 }, '13' ], + [ '20', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + // Select 20 -> 01 (selection 3x3) tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 0, 0 ] ), - modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + modelRoot.getNodeByPath( [ 0, 2, 0 ] ), + modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); + // +----+----+----+ + // | aa | ab | ac | + // +----+----+----+ + // | ba | bc | + // + +----+ + // | | cc | + // +----+----+----+ pasteTable( [ [ 'aa', 'ab', 'ac' ], - [ 'ba', 'bb', 'bc' ], - [ 'ca', 'cb', 'cc' ] + [ { contents: 'ba', colspan: 2, rowspan: 2 }, 'bc' ], + [ 'cc' ] ] ); assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ - [ 'aa', 'ab', '02', '03' ], - [ 'ba', 'bb', '12', '13' ], - [ '20', '21', '22', '23' ], + [ 'aa', 'ab', 'ac', '03' ], + [ { contents: 'ba', colspan: 2, rowspan: 2 }, 'bc', '13' ], + [ 'cc', '23' ], [ '30', '31', '32', '33' ] ] ) ); + /* eslint-disable no-multi-spaces */ assertSelectedCells( model, [ - [ 1, 1, 0, 0 ], - [ 1, 1, 0, 0 ], - [ 0, 0, 0, 0 ], + [ 1, 1, 1, 0 ], + [ 1, 1, 0 ], + [ 1, 0 ], [ 0, 0, 0, 0 ] ] ); + /* eslint-enable no-multi-spaces */ } ); + } ); - it( 'handles simple table paste to a simple table fragment - at the end of a table', () => { + describe( 'non-rectangular content table selection', () => { + it( 'should split cells outside the selected area before pasting (rowspan ends in selection)', () => { + // +----+----+----+ + // | 00 | 01 | 02 | + // +----+ +----+ + // | 10 | | 12 | + // +----+ +----+ + // | 20 | | 22 | + // +----+ +----+ + // | 30 | | 32 | + // +----+----+----+ + // | 40 | 41 | 42 | + // +----+----+----+ + setModelData( model, modelTable( [ + [ '00', { contents: '01', rowspan: 4 }, '02' ], + [ '10', '12' ], + [ '20', '22' ], + [ '30', '32' ], + [ '40', '41', '42' ] + ] ) ); + + // Select 20 -> 32 tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 2, 2 ] ), - modelRoot.getNodeByPath( [ 0, 3, 3 ] ) + modelRoot.getNodeByPath( [ 0, 2, 0 ] ), + modelRoot.getNodeByPath( [ 0, 3, 1 ] ) ); pasteTable( [ [ 'aa', 'ab', 'ac' ], - [ 'ba', 'bb', 'bc' ], - [ 'ca', 'cb', 'cc' ] + [ 'ba', 'bb', 'bc' ] ] ); + // +----+----+----+ + // | 00 | 01 | 02 | + // +----+ +----+ + // | 10 | | 12 | + // +----+----+----+ + // | aa | ab | ac | + // +----+----+----+ + // | ba | bb | bc | + // +----+----+----+ + // | 40 | 41 | 42 | + // +----+----+----+ assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ - [ '00', '01', '02', '03' ], - [ '10', '11', '12', '13' ], - [ '20', '21', 'aa', 'ab' ], - [ '30', '31', 'ba', 'bb' ] + [ '00', { contents: '01', rowspan: 2 }, '02' ], + [ '10', '12' ], + [ 'aa', 'ab', 'ac' ], + [ 'ba', 'bb', 'bc' ], + [ '40', '41', '42' ] ] ) ); - - assertSelectedCells( model, [ - [ 0, 0, 0, 0 ], - [ 0, 0, 0, 0 ], - [ 0, 0, 1, 1 ], - [ 0, 0, 1, 1 ] - ] ); } ); - it( 'handles simple table paste to a simple table fragment - in the middle of a table', () => { + it( 'should split cells outside the selected area before pasting (rowspan ends after the selection)', () => { + // +----+----+----+ + // | 00 | 01 | 02 | + // +----+ +----+ + // | 10 | | 12 | + // +----+ +----+ + // | 20 | | 22 | + // +----+ +----+ + // | 30 | | 32 | + // +----+----+----+ + // | 40 | 41 | 42 | + // +----+----+----+ + setModelData( model, modelTable( [ + [ '00', { contents: '01', rowspan: 4 }, '02' ], + [ '10', '12' ], + [ '20', '22' ], + [ '30', '32' ], + [ '40', '41', '42' ] + ] ) ); + + // Select 10 -> 22 tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 1, 1 ] ), - modelRoot.getNodeByPath( [ 0, 2, 2 ] ) + modelRoot.getNodeByPath( [ 0, 1, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 1 ] ) ); pasteTable( [ [ 'aa', 'ab', 'ac' ], - [ 'ba', 'bb', 'bc' ], - [ 'ca', 'cb', 'cc' ] + [ 'ba', 'bb', 'bc' ] ] ); + // +----+----+----+ + // | 00 | 01 | 02 | + // +----+----+----+ + // | aa | ab | ac | + // +----+----+----+ + // | ba | bb | bc | + // +----+----+----+ + // | 30 | | 32 | + // +----+----+----+ + // | 40 | 41 | 42 | + // +----+----+----+ assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ - [ '00', '01', '02', '03' ], - [ '10', 'aa', 'ab', '13' ], - [ '20', 'ba', 'bb', '23' ], - [ '30', '31', '32', '33' ] + [ '00', '01', '02' ], + [ 'aa', 'ab', 'ac' ], + [ 'ba', 'bb', 'bc' ], + [ '30', '', '32' ], + [ '40', '41', '42' ] ] ) ); - - assertSelectedCells( model, [ - [ 0, 0, 0, 0 ], - [ 0, 1, 1, 0 ], - [ 0, 1, 1, 0 ], - [ 0, 0, 0, 0 ] - ] ); } ); - it( 'handles paste to a simple row fragment - in the middle of a table', () => { + it( 'should split cells inside the selected area before pasting (rowspan ends after the selection)', () => { + // +----+----+----+ + // | 00 | 01 | 02 | + // +----+ +----+ + // | 10 | | 12 | + // +----+ +----+ + // | 20 | | 22 | + // +----+ +----+ + // | 30 | | 32 | + // +----+----+----+ + // | 40 | 41 | 42 | + // +----+----+----+ + setModelData( model, modelTable( [ + [ '00', { contents: '01', rowspan: 4 }, '02' ], + [ '10', '12' ], + [ '20', '22' ], + [ '30', '32' ], + [ '40', '41', '42' ] + ] ) ); + + // Select 00 -> 12 tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 1, 1 ] ), - modelRoot.getNodeByPath( [ 0, 1, 2 ] ) + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab', 'ac' ], + [ 'ba', 'bb', 'bc' ] + ] ); + + // +----+----+----+ + // | aa | ab | ac | + // +----+----+----+ + // | ba | bb | bc | + // +----+----+----+ + // | 20 | | 22 | + // +----+ +----+ + // | 30 | | 32 | + // +----+----+----+ + // | 40 | 41 | 42 | + // +----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', 'ab', 'ac' ], + [ 'ba', 'bb', 'bc' ], + [ '20', { rowspan: 2, contents: '' }, '22' ], + [ '30', '32' ], + [ '40', '41', '42' ] + ] ) ); + } ); + + it( 'should split cells outside the selected area before pasting (colspan ends in selection)', () => { + // +----+----+----+----+----+ + // | 00 | 01 | 02 | 03 | 04 | + // +----+----+----+----+----+ + // | 10 | 14 | + // +----+----+----+----+----+ + // | 20 | 21 | 22 | 23 | 24 | + // +----+----+----+----+----+ + setModelData( model, modelTable( [ + [ '00', '01', '02', '03', '04' ], + [ { contents: '10', colspan: 4 }, '14' ], + [ '20', '21', '22', '23', '24' ] + ] ) ); + + // Select 02 -> 23 + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 2 ] ), + modelRoot.getNodeByPath( [ 0, 2, 3 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab' ], + [ 'ba', 'bb' ], + [ 'ca', 'cb' ] + ] ); + + // +----+----+----+----+----+ + // | 00 | 01 | aa | ab | 04 | + // +----+----+----+----+----+ + // | 10 | ba | bb | 14 | + // +----+----+----+----+----+ + // | 20 | 21 | ca | cb | 24 | + // +----+----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', 'aa', 'ab', '04' ], + [ { contents: '10', colspan: 2 }, 'ba', 'bb', '14' ], + [ '20', '21', 'ca', 'cb', '24' ] + ] ) ); + } ); + + it( 'should split cells outside the selected area before pasting (colspan ends after the selection)', () => { + // +----+----+----+----+----+ + // | 00 | 01 | 02 | 03 | 04 | + // +----+----+----+----+----+ + // | 10 | 14 | + // +----+----+----+----+----+ + // | 20 | 21 | 22 | 23 | 24 | + // +----+----+----+----+----+ + setModelData( model, modelTable( [ + [ '00', '01', '02', '03', '04' ], + [ { contents: '10', colspan: 4 }, '14' ], + [ '20', '21', '22', '23', '24' ] + ] ) ); + + // Select 01 -> 22 + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 1 ] ), + modelRoot.getNodeByPath( [ 0, 2, 2 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab' ], + [ 'ba', 'bb' ], + [ 'ca', 'cb' ] + ] ); + + // +----+----+----+----+----+ + // | 00 | aa | ab | 03 | 04 | + // +----+----+----+----+----+ + // | 10 | ba | bb | | 14 | + // +----+----+----+----+----+ + // | 20 | ca | cb | 23 | 24 | + // +----+----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', 'aa', 'ab', '03', '04' ], + [ '10', 'ba', 'bb', '', '14' ], + [ '20', 'ca', 'cb', '23', '24' ] + ] ) ); + } ); + + it( 'should split cells inside the selected area before pasting (colspan ends after the selection)', () => { + // +----+----+----+----+----+ + // | 00 | 01 | 02 | 03 | 04 | + // +----+----+----+----+----+ + // | 10 | 14 | + // +----+----+----+----+----+ + // | 20 | 21 | 22 | 23 | 24 | + // +----+----+----+----+----+ + setModelData( model, modelTable( [ + [ '00', '01', '02', '03', '04' ], + [ { contents: '10', colspan: 4 }, '14' ], + [ '20', '21', '22', '23', '24' ] + ] ) ); + + // Select 00 -> 21 + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 1 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab' ], + [ 'ba', 'bb' ], + [ 'ca', 'cb' ] + ] ); + + // +----+----+----+----+----+ + // | aa | ab | 02 | 03 | 04 | + // +----+----+----+----+----+ + // | ba | bb | | 14 | + // +----+----+----+----+----+ + // | ca | cb | 22 | 23 | 24 | + // +----+----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', 'ab', '02', '03', '04' ], + [ 'ba', 'bb', { colspan: 2, contents: '' }, '14' ], + [ 'ca', 'cb', '22', '23', '24' ] + ] ) ); + } ); + + it( 'should split cells anchored outside selection rectangle that overlaps selection (above selection)', () => { + // +----+----+----+ + // | 00 | 02 | + // + +----+ + // | | 12 | + // +----+----+----+ + // | 20 | 21 | 22 | + // +----+----+----+ + setModelData( model, modelTable( [ + [ { contents: '00', colspan: 2, rowspan: 2 }, '02' ], + [ '12' ], + [ '20', '21', '22' ] + ] ) ); + + // Select 21 -> 12 + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 2, 1 ] ), + modelRoot.getNodeByPath( [ 0, 1, 0 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab' ], + [ 'ba', 'bb' ] + ] ); + + // +----+----+----+ + // | 00 | | 02 | + // + +----+----+ + // | | aa | ab | + // +----+----+----+ + // | 20 | ba | bb | + // +----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ { contents: '00', rowspan: 2 }, '', '02' ], + [ 'aa', 'ab' ], + [ '20', 'ba', 'bb' ] + ] ) ); + } ); + + it( 'should split cells anchored outside selection rectangle that overlaps selection (below selection)', () => { + // +----+----+----+ + // | 00 | 01 | 02 | + // +----+----+----+ + // | 10 | 12 | + // + +----+ + // | | 22 | + // +----+----+----+ + setModelData( model, modelTable( [ + [ '00', '01', '02' ], + [ { contents: '10', colspan: 2, rowspan: 2 }, '12' ], + [ '22' ] + ] ) ); + + // Select 01 -> 12 + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 1 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab' ], + [ 'ba', 'bb' ] + ] ); + + // +----+----+----+ + // | 00 | aa | ab | + // +----+----+----+ + // | 10 | ba | bb | + // + +----+----+ + // | | | 22 | + // +----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', 'aa', 'ab' ], + [ { contents: '10', rowspan: 2 }, 'ba', 'bb' ], + [ '', '22' ] + ] ) ); + } ); + + it( 'should properly handle complex case', () => { + // +----+----+----+----+----+----+----+ + // | 00 | 03 | 04 | + // + + +----+----+----+ + // | | | 14 | 15 | 16 | + // + + +----+----+----+ + // | | | 24 | + // +----+----+----+----+----+----+----+ + // | 30 | 31 | 33 | 34 | 35 | 36 | + // + +----+----+ +----+----+----+ + // | | 41 | 42 | | 44 | 45 | + // + +----+----+ +----+ + + // | | 51 | 52 | | 54 | | + // +----+----+----+ +----+ + + // | 60 | 61 | 62 | | 64 | | + // +----+----+----+----+----+----+----+ + setModelData( model, modelTable( [ + [ { contents: '00', colspan: 3, rowspan: 3 }, { contents: '03', rowspan: 3 }, { colspan: 3, contents: '04' } ], + [ '14', '15', '16' ], + [ { contents: '24', colspan: 3 } ], + [ + { contents: '30', rowspan: 3 }, + { contents: '31', colspan: 2 }, + { contents: '33', rowspan: 4 }, + '34', '35', '36' + ], + [ '41', '42', '44', { contents: '45', colspan: 2, rowspan: 3 } ], + [ '51', '52', '54' ], + [ '60', '61', '62', '64' ] + ] ) ); + + // Select 42 -> 24 (3x3 selection) + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 4, 1 ] ), + modelRoot.getNodeByPath( [ 0, 2, 0 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab', 'ac' ], + [ 'ba', 'bb', 'bc' ], + [ 'ca', 'cb', 'cc' ] + ] ); + + // +----+----+----+----+----+----+----+ + // | 00 | | 03 | 04 | + // + + + +----+----+----+ + // | | | | 14 | 15 | 16 | + // + +----+----+----+----+----+ + // | | aa | ab | ac | | + // +----+----+----+----+----+----+----+ + // | 30 | 31 | ba | bb | bc | 35 | 36 | + // + +----+----+----+----+----+----+ + // | | 41 | ca | cb | cc | 45 | + // + +----+----+----+----+ + + // | | 51 | 52 | | 54 | | + // +----+----+----+ +----+ + + // | 60 | 61 | 62 | | 64 | | + // +----+----+----+----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ + { contents: '00', colspan: 2, rowspan: 3 }, + { contents: '', rowspan: 2 }, + { contents: '03', rowspan: 2 }, + { contents: '04', colspan: 3 } + ], + [ '14', '15', '16' ], + [ 'aa', 'ab', 'ac', { contents: '', colspan: 2 } ], + [ { contents: '30', rowspan: 3 }, '31', 'ba', 'bb', 'bc', '35', '36' ], + [ '41', 'ca', 'cb', 'cc', { contents: '45', colspan: 2, rowspan: 3 } ], + [ '51', '52', { contents: '', rowspan: 2 }, '54' ], + [ '60', '61', '62', '64' ] + ] ) ); + } ); + } ); + } ); + + describe( 'pasted table is bigger than the selected area', () => { + describe( 'no spans', () => { + it( 'handles simple table paste to a simple table fragment - at the beginning of a table', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab', 'ac' ], + [ 'ba', 'bb', 'bc' ], + [ 'ca', 'cb', 'cc' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', 'ab', '02', '03' ], + [ 'ba', 'bb', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + assertSelectedCells( model, [ + [ 1, 1, 0, 0 ], + [ 1, 1, 0, 0 ], + [ 0, 0, 0, 0 ], + [ 0, 0, 0, 0 ] + ] ); + } ); + + it( 'handles simple table paste to a simple table fragment - at the end of a table', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 2, 2 ] ), + modelRoot.getNodeByPath( [ 0, 3, 3 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab', 'ac' ], + [ 'ba', 'bb', 'bc' ], + [ 'ca', 'cb', 'cc' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03' ], + [ '10', '11', '12', '13' ], + [ '20', '21', 'aa', 'ab' ], + [ '30', '31', 'ba', 'bb' ] + ] ) ); + + assertSelectedCells( model, [ + [ 0, 0, 0, 0 ], + [ 0, 0, 0, 0 ], + [ 0, 0, 1, 1 ], + [ 0, 0, 1, 1 ] + ] ); + } ); + + it( 'handles simple table paste to a simple table fragment - in the middle of a table', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 1 ] ), + modelRoot.getNodeByPath( [ 0, 2, 2 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab', 'ac' ], + [ 'ba', 'bb', 'bc' ], + [ 'ca', 'cb', 'cc' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03' ], + [ '10', 'aa', 'ab', '13' ], + [ '20', 'ba', 'bb', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + assertSelectedCells( model, [ + [ 0, 0, 0, 0 ], + [ 0, 1, 1, 0 ], + [ 0, 1, 1, 0 ], + [ 0, 0, 0, 0 ] + ] ); + } ); + + it( 'handles paste to a simple row fragment - in the middle of a table', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 1 ] ), + modelRoot.getNodeByPath( [ 0, 1, 2 ] ) ); pasteTable( [ @@ -1997,6 +2609,294 @@ describe( 'table clipboard', () => { ] ); } ); } ); + + describe( 'fixing pasted table broken layout', () => { + it( 'should trim pasted cells\' width if they exceeds table width established by the first row', () => { + // Select 00 -> 22 (selection 2x3 - equal to expected fixed table) + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 1 ] ) + ); + + // +----+----+ + // | aa | ab | <- First row establish table width=2. + // +----+----+----+----+ + // | ba | bb | <- Cell "bb" sticks out by 2 slots. + // +----+----+----+----+ + // | ca | <- Cell "ca" sticks out by 1 slot. + // +----+----+----+ + pasteTable( [ + [ 'aa', 'ab' ], + [ 'ba', { colspan: 3, contents: 'bb' } ], + [ { colspan: 3, contents: 'ca' } ] + ] ); + + // +----+----+----+----+ + // | aa | ab | 02 | 03 | + // +----+----+----+----+ + // | ba | bb | 12 | 13 | + // +----+----+----+----+ + // | ca | 22 | 23 | + // +----+----+----+----+ + // | 30 | 31 | 32 | 33 | + // +----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', 'ab', '02', '03' ], + [ 'ba', 'bb', '12', '13' ], + [ { contents: 'ca', colspan: 2 }, '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 1, 1, 0, 0 ], + [ 1, 1, 0, 0 ], + [ 1, 0, 0 ], + [ 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( 'should trim pasted cells\' height if they exceeds table height established by the last row', () => { + // Select 00 -> 12 (selection 3x2 - equal to expected fixed table) + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 2 ] ) + ); + + // +----+----+----+ + // | aa | ab | ac | + // +----+----+ + + // | ba | bb | | <- Last row establish table height=2. + // +----+ + + + // | | | <- Cell "ac" sticks out by 1 slot. + // + +----+ + // | | <- Cell "bb" sticks out by 2 slots. + // +----+ + pasteTable( [ + [ 'aa', 'ab', { contents: 'ac', rowspan: 3 } ], + [ 'ba', { contents: 'bb', rowspan: 3 } ] + ] ); + + // +----+----+----+----+ + // | aa | ab | ac | 03 | + // +----+----+ +----+ + // | ba | bb | | 13 | + // +----+----+----+----+ + // | 20 | 21 | 22 | 23 | + // +----+----+----+----+ + // | 30 | 31 | 32 | 33 | + // +----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', 'ab', { rowspan: 2, contents: 'ac' }, '03' ], + [ 'ba', 'bb', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 1, 1, 1, 0 ], + [ 1, 1, 0 ], + [ 0, 0, 0, 0 ], + [ 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( 'should trim pasted cells\' height and width if they exceeds table height and width', () => { + // Select 00 -> 11 (selection 2x2 - equal to expected fixed table) + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + // +----+----+ + // | aa | ab | + // +----+----+----+----+ + // | ba | bb | <- Cell "bb" sticks out by 2 slots in width and by 1 slot in height. + // +----+ + + // | | + // +----+----+----+ + pasteTable( [ + [ 'aa', 'ab' ], + [ 'ba', { contents: 'bb', colspan: 3, rowspan: 2 } ] + ] ); + + // +----+----+----+----+ + // | aa | ab | 02 | 03 | + // +----+----+----+----+ + // | ba | bb | 12 | 13 | + // +----+----+----+----+ + // | 20 | 21 | 22 | 23 | + // +----+----+----+----+ + // | 30 | 31 | 32 | 33 | + // +----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', 'ab', '02', '03' ], + [ 'ba', 'bb', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 1, 1, 0, 0 ], + [ 1, 1, 0, 0 ], + [ 0, 0, 0, 0 ], + [ 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( + 'should trim pasted cells\' width if they exceeds pasted table width (pasted height is bigger then selection height)', + () => { + // Select 00 -> 11 (selection 2x2 - smaller by height than the expected fixed table) + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + // +----+----+ + // | aa | ab | <- First row establish table width=2. + // +----+----+----+----+ + // | ba | bb | <- Cell "bb" sticks out by 2 slots. + // +----+----+----+----+ + // | ca | <- Cell "ca" sticks out by 1 slot. + // +----+----+----+ + pasteTable( [ + [ 'aa', 'ab' ], + [ 'ba', { colspan: 3, contents: 'bb' } ], + [ { colspan: 3, contents: 'ca' } ] + ] ); + + // +----+----+----+----+ + // | aa | ab | 02 | 03 | + // +----+----+----+----+ + // | ba | bb | 12 | 13 | + // +----+----+----+----+ + // | 20 | 21 | 22 | 23 | + // +----+----+----+----+ + // | 30 | 31 | 32 | 33 | + // +----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', 'ab', '02', '03' ], + [ 'ba', 'bb', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + assertSelectedCells( model, [ + [ 1, 1, 0, 0 ], + [ 1, 1, 0, 0 ], + [ 0, 0, 0, 0 ], + [ 0, 0, 0, 0 ] + ] ); + } + ); + + it( + 'should trim pasted cells\' height if they exceeds pasted table height (pasted width is bigger then selection width)', + () => { + // Select 00 -> 11 (selection 2x2 - smaller by width than the expected fixed table) + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + // +----+----+----+ + // | aa | ab | ac | + // +----+----+ + + // | ba | bb | | <- Last row establish table height=2. + // +----+ + + + // | | | <- Cell "ac" sticks out by 1 slot. + // + +----+ + // | | <- Cell "bb" sticks out by 2 slots. + // +----+ + pasteTable( [ + [ 'aa', 'ab', { contents: 'ac', rowspan: 3 } ], + [ 'ba', { contents: 'bb', rowspan: 3 } ] + ] ); + + // +----+----+----+----+ + // | aa | ab | 02 | 03 | + // +----+----+----+----+ + // | ba | bb | 12 | 13 | + // +----+----+----+----+ + // | 20 | 21 | 22 | 23 | + // +----+----+----+----+ + // | 30 | 31 | 32 | 33 | + // +----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', 'ab', '02', '03' ], + [ 'ba', 'bb', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 1, 1, 0, 0 ], + [ 1, 1, 0, 0 ], + [ 0, 0, 0, 0 ], + [ 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } + ); + + it( + `should trim pasted pasted cells' height and width if they exceeds pasted table dimensions + (pasted table is bigger then selection width)`, + () => { + // Select 00 -> 11 (selection 2x2 - smaller than the expected fixed table) + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + // +----+----+----+ + // | aa | ab | ac | + // +----+----+----+----+ + // | ba | bb | <- Cell "bb" sticks out by 1 slots in width and by 1 slot in height. + // +----+ + + // | ca | | + // +----+ + + // | | + // +----+----+----+ + pasteTable( [ + [ 'aa', 'ab' ], + [ 'ba', { contents: 'bb', colspan: 3, rowspan: 2 } ] + ] ); + + // +----+----+----+----+ + // | aa | ab | 02 | 03 | + // +----+----+----+----+ + // | ba | bb | 12 | 13 | + // +----+----+----+----+ + // | 20 | 21 | 22 | 23 | + // +----+----+----+----+ + // | 30 | 31 | 32 | 33 | + // +----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', 'ab', '02', '03' ], + [ 'ba', 'bb', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 1, 1, 0, 0 ], + [ 1, 1, 0, 0 ], + [ 0, 0, 0, 0 ], + [ 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } + ); + } ); } ); describe( 'Clipboard integration - paste (content scenarios)', () => { diff --git a/packages/ckeditor5-table/tests/utils.js b/packages/ckeditor5-table/tests/utils.js index 7815e5d19d1..14fe46bbab2 100644 --- a/packages/ckeditor5-table/tests/utils.js +++ b/packages/ckeditor5-table/tests/utils.js @@ -13,7 +13,7 @@ import { modelTable } from './_utils/utils'; import { getSelectedTableCells, getTableCellsContainingSelection, - getSelectionAffectedTableCells + getSelectionAffectedTableCells, getVerticallyOverlappingCells, getHorizontallyOverlappingCells } from '../src/utils'; describe( 'table utils', () => { @@ -359,4 +359,92 @@ describe( 'table utils', () => { expect( getSelectionAffectedTableCells( selection ) ).to.be.empty; } ); } ); + + describe( 'getVerticallyOverlappingCells()', () => { + let table; + + beforeEach( () => { + // +----+----+----+----+----+ + // | 00 | 01 | 02 | 03 | 04 | + // + + +----+ +----+ + // | | | 12 | | 14 | + // + + + +----+----+ + // | | | | 23 | 24 | + // + +----+ + +----+ + // | | 31 | | | 34 | + // + + +----+----+----+ + // | | | 42 | 43 | 44 | + // +----+----+----+----+----+ + setModelData( model, modelTable( [ + [ { contents: '00', rowspan: 5 }, { contents: '01', rowspan: 3 }, '02', { contents: '03', rowspan: 2 }, '04' ], + [ { contents: '12', rowspan: 3 }, '14' ], + [ { contents: '23', rowspan: 2 }, '24' ], + [ { contents: '31', rowspan: 2 }, '34' ], + [ '42', '43', '44' ] + ] ) ); + + table = modelRoot.getChild( 0 ); + } ); + + it( 'should return empty array for no overlapping cells', () => { + const cellsInfo = getVerticallyOverlappingCells( table, 0 ); + + expect( cellsInfo ).to.be.empty; + } ); + + it( 'should return overlapping cells info for given overlapRow', () => { + const cellsInfo = getVerticallyOverlappingCells( table, 2 ); + + expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); // Cell 00 + expect( cellsInfo[ 1 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); // Cell 01 + expect( cellsInfo[ 2 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 12 + } ); + + it( 'should ignore rows below startRow', () => { + const cellsInfo = getVerticallyOverlappingCells( table, 2, 1 ); + + expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 12 + } ); + } ); + + describe( 'getHorizontallyOverlappingCells()', () => { + let table; + + beforeEach( () => { + // +----+----+----+----+----+ + // | 00 | + // +----+----+----+----+----+ + // | 10 | 13 | + // +----+----+----+----+----+ + // | 20 | 21 | 24 | + // +----+----+----+----+----+ + // | 30 | 32 | 34 | + // +----+----+----+----+----+ + // | 40 | 41 | 42 | 43 | 44 | + // +----+----+----+----+----+ + setModelData( model, modelTable( [ + [ { contents: '00', colspan: 5 } ], + [ { contents: '10', colspan: 3 }, { contents: '13', colspan: 2 } ], + [ '20', { contents: '21', colspan: 3 }, '24' ], + [ { contents: '30', colspan: 2 }, { contents: '32', colspan: 2 }, '34' ], + [ '40', '41', '42', '43', '44' ] + ] ) ); + + table = modelRoot.getChild( 0 ); + } ); + + it( 'should return empty array for no overlapping cells', () => { + const cellsInfo = getHorizontallyOverlappingCells( table, 0 ); + + expect( cellsInfo ).to.be.empty; + } ); + + it( 'should return overlapping cells info for given overlapColumn', () => { + const cellsInfo = getHorizontallyOverlappingCells( table, 2 ); + + expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); // Cell 00 + expect( cellsInfo[ 1 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 10 + expect( cellsInfo[ 2 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) ); // Cell 21 + } ); + } ); } );