|
|
@@ -11,9 +11,18 @@ 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 { cropTableToDimensions, trimTableCellIfNeeded } 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,61 +129,96 @@ 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.
|
|
|
- // 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.' );
|
|
|
-
|
|
|
- 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 pastedDimensions = {
|
|
|
+ width: pasteWidth,
|
|
|
+ height: pasteHeight
|
|
|
+ };
|
|
|
const selectionDimensions = {
|
|
|
firstColumnOfSelection,
|
|
|
firstRowOfSelection,
|
|
|
lastColumnOfSelection,
|
|
|
- lastRowOfSelection,
|
|
|
- selectionHeight,
|
|
|
- selectionWidth
|
|
|
+ lastRowOfSelection
|
|
|
};
|
|
|
|
|
|
- replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDimensions, writer );
|
|
|
+ replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selectionDimensions, writer );
|
|
|
} );
|
|
|
}
|
|
|
}
|
|
|
@@ -185,23 +226,26 @@ export default class TableClipboard extends Plugin {
|
|
|
// Replaces the part of selectedTable with pastedTable.
|
|
|
//
|
|
|
// @param {module:engine/model/element~Element} pastedTable
|
|
|
+// @param {Object} pastedDimensions
|
|
|
+// @param {Number} pastedDimensions.height
|
|
|
+// @param {Number} pastedDimensions.width
|
|
|
// @param {module:engine/model/element~Element} selectedTable
|
|
|
// @param {Object} selectionDimensions
|
|
|
// @param {Number} selectionDimensions.firstColumnOfSelection
|
|
|
// @param {Number} selectionDimensions.firstRowOfSelection
|
|
|
// @param {Number} selectionDimensions.lastColumnOfSelection
|
|
|
// @param {Number} selectionDimensions.lastRowOfSelection
|
|
|
-// @param {Number} selectionDimensions.selectionHeight
|
|
|
-// @param {Number} selectionDimensions.selectionWidth
|
|
|
// @param {module:engine/model/writer~Writer} writer
|
|
|
-function replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDimensions, writer ) {
|
|
|
+function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selectionDimensions, writer ) {
|
|
|
const {
|
|
|
- firstColumnOfSelection, lastColumnOfSelection, selectionWidth,
|
|
|
- firstRowOfSelection, lastRowOfSelection, selectionHeight
|
|
|
+ firstColumnOfSelection, lastColumnOfSelection,
|
|
|
+ firstRowOfSelection, lastRowOfSelection
|
|
|
} = selectionDimensions;
|
|
|
|
|
|
+ const { width: pastedWidth, height: pastedHeight } = pastedDimensions;
|
|
|
+
|
|
|
// Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
|
|
|
- const pastedTableLocationMap = createLocationMap( pastedTable, selectionWidth, selectionHeight );
|
|
|
+ const pastedTableLocationMap = createLocationMap( pastedTable, pastedWidth, pastedHeight );
|
|
|
|
|
|
const selectedTableMap = [ ...new TableWalker( selectedTable, {
|
|
|
startRow: firstRowOfSelection,
|
|
|
@@ -245,7 +289,9 @@ function replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDi
|
|
|
}
|
|
|
|
|
|
// Map current table slot location to an pasted table slot location.
|
|
|
- const pastedCell = pastedTableLocationMap[ row - firstRowOfSelection ][ column - firstColumnOfSelection ];
|
|
|
+ const pastedRow = row - firstRowOfSelection;
|
|
|
+ const pastedColumn = column - firstColumnOfSelection;
|
|
|
+ const pastedCell = pastedTableLocationMap[ pastedRow % pastedHeight ][ pastedColumn % pastedWidth ];
|
|
|
|
|
|
// There is no cell to insert (might be spanned by other cell in a pasted table) - advance to the next content table slot.
|
|
|
if ( !pastedCell ) {
|
|
|
@@ -256,6 +302,9 @@ function replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDi
|
|
|
// Cloning is required to support repeating pasted table content when inserting to a bigger selection.
|
|
|
const cellToInsert = pastedCell._clone( true );
|
|
|
|
|
|
+ // Trim the cell if it's row/col-spans would exceed selection area.
|
|
|
+ trimTableCellIfNeeded( cellToInsert, row, column, lastRowOfSelection, lastColumnOfSelection, writer );
|
|
|
+
|
|
|
let insertPosition;
|
|
|
|
|
|
if ( !previousCellInRow ) {
|
|
|
@@ -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;
|
|
|
+}
|