diff --git a/packages/ckeditor5-table/src/commands/mergecellscommand.js b/packages/ckeditor5-table/src/commands/mergecellscommand.js index 2c3ee36d498..6b533632a00 100644 --- a/packages/ckeditor5-table/src/commands/mergecellscommand.js +++ b/packages/ckeditor5-table/src/commands/mergecellscommand.js @@ -8,9 +8,9 @@ */ import Command from '@ckeditor/ckeditor5-core/src/command'; -import { findAncestor, updateNumericAttribute } from './utils'; import TableUtils from '../tableutils'; -import { getColumnIndexes, getRowIndexes, getSelectedTableCells } from '../utils'; +import { findAncestor, updateNumericAttribute } from './utils'; +import { isSelectionRectangular, getSelectedTableCells } from '../utils'; /** * The merge cells command. @@ -28,7 +28,8 @@ export default class MergeCellsCommand extends Command { * @inheritDoc */ refresh() { - this.isEnabled = canMergeCells( this.editor.model.document.selection, this.editor.plugins.get( TableUtils ) ); + const selectedTableCells = getSelectedTableCells( this.editor.model.document.selection ); + this.isEnabled = isSelectionRectangular( selectedTableCells, this.editor.plugins.get( TableUtils ) ); } /** @@ -107,132 +108,6 @@ function isEmpty( tableCell ) { return tableCell.childCount == 1 && tableCell.getChild( 0 ).is( 'paragraph' ) && tableCell.getChild( 0 ).isEmpty; } -// Checks if the selection contains cells that can be merged. -// -// In a table below: -// -// ┌───┬───┬───┬───┐ -// │ a │ b │ c │ d │ -// ├───┴───┼───┤ │ -// │ e │ f │ │ -// ├ ├───┼───┤ -// │ │ g │ h │ -// └───────┴───┴───┘ -// -// Valid selections are these which create a solid rectangle (without gaps), such as: -// - a, b (two horizontal cells) -// - c, f (two vertical cells) -// - a, b, e (cell "e" spans over four cells) -// - c, d, f (cell d spans over a cell in the row below) -// -// While an invalid selection would be: -// - a, c (the unselected cell "b" creates a gap) -// - f, g, h (cell "d" spans over a cell from the row of "f" cell - thus creates a gap) -// -// @param {module:engine/model/selection~Selection} selection -// @param {module:table/tableUtils~TableUtils} tableUtils -// @returns {boolean} -function canMergeCells( selection, tableUtils ) { - const selectedTableCells = getSelectedTableCells( selection ); - - if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) { - return false; - } - - // A valid selection is a fully occupied rectangle composed of table cells. - // Below we will calculate the area of a selected table cells and the area of valid selection. - // The area of a valid selection is defined by top-left and bottom-right cells. - const rows = new Set(); - const columns = new Set(); - - let areaOfSelectedCells = 0; - - for ( const tableCell of selectedTableCells ) { - const { row, column } = tableUtils.getCellLocation( tableCell ); - const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 ); - const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); - - // Record row & column indexes of current cell. - rows.add( row ); - columns.add( column ); - - // For cells that spans over multiple rows add also the last row that this cell spans over. - if ( rowspan > 1 ) { - rows.add( row + rowspan - 1 ); - } - - // For cells that spans over multiple columns add also the last column that this cell spans over. - if ( colspan > 1 ) { - columns.add( column + colspan - 1 ); - } - - areaOfSelectedCells += ( rowspan * colspan ); - } - - // We can only merge table cells that are in adjacent rows... - const areaOfValidSelection = getBiggestRectangleArea( rows, columns ); - - return areaOfValidSelection == areaOfSelectedCells; -} - -// Calculates the area of a maximum rectangle that can span over the provided row & column indexes. -// -// @param {Array.} rows -// @param {Array.} columns -// @returns {Number} -function getBiggestRectangleArea( rows, columns ) { - const rowsIndexes = Array.from( rows.values() ); - const columnIndexes = Array.from( columns.values() ); - - const lastRow = Math.max( ...rowsIndexes ); - const firstRow = Math.min( ...rowsIndexes ); - const lastColumn = Math.max( ...columnIndexes ); - const firstColumn = Math.min( ...columnIndexes ); - - return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 ); -} - -// Checks if the selection does not mix a header (column or row) with other cells. -// -// For instance, in the table below valid selections consist of cells with the same letter only. -// So, a-a (same heading row and column) or d-d (body cells) are valid while c-d or a-b are not. -// -// header columns -// ↓ ↓ -// ┌───┬───┬───┬───┐ -// │ a │ a │ b │ b │ ← header row -// ├───┼───┼───┼───┤ -// │ c │ c │ d │ d │ -// ├───┼───┼───┼───┤ -// │ c │ c │ d │ d │ -// └───┴───┴───┴───┘ -// -function areCellInTheSameTableSection( tableCells ) { - const table = findAncestor( 'table', tableCells[ 0 ] ); - - const rowIndexes = getRowIndexes( tableCells ); - const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 ); - - // Calculating row indexes is a bit cheaper so if this check fails we can't merge. - if ( !areIndexesInSameSection( rowIndexes, headingRows ) ) { - return false; - } - - const headingColumns = parseInt( table.getAttribute( 'headingColumns' ) || 0 ); - const columnIndexes = getColumnIndexes( tableCells ); - - // Similarly cells must be in same column section. - return areIndexesInSameSection( columnIndexes, headingColumns ); -} - -// Unified check if table rows/columns indexes are in the same heading/body section. -function areIndexesInSameSection( { first, last }, headingSectionSize ) { - const firstCellIsInHeading = first < headingSectionSize; - const lastCellIsInHeading = last < headingSectionSize; - - return firstCellIsInHeading === lastCellIsInHeading; -} - function getMergeDimensions( firstTableCell, selectedTableCells, tableUtils ) { let maxWidthOffset = 0; let maxHeightOffset = 0; diff --git a/packages/ckeditor5-table/src/tableclipboard.js b/packages/ckeditor5-table/src/tableclipboard.js index 8486da2e058..9ae9e7af8fd 100644 --- a/packages/ckeditor5-table/src/tableclipboard.js +++ b/packages/ckeditor5-table/src/tableclipboard.js @@ -8,7 +8,13 @@ */ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; + import TableSelection from './tableselection'; +import TableWalker from './tablewalker'; +import { getColumnIndexes, getRowIndexes, isSelectionRectangular } from './utils'; +import { findAncestor } from './commands/utils'; +import { cropTableToDimensions } from './tableselection/croptable'; +import TableUtils from './tableutils'; /** * This plugin adds support for copying/cutting/pasting fragments of tables. @@ -28,7 +34,7 @@ export default class TableClipboard extends Plugin { * @inheritDoc */ static get requires() { - return [ TableSelection ]; + return [ TableSelection, TableUtils ]; } /** @@ -40,6 +46,7 @@ export default class TableClipboard extends Plugin { this.listenTo( viewDocument, 'copy', ( evt, data ) => this._onCopyCut( evt, data ) ); this.listenTo( viewDocument, 'cut', ( evt, data ) => this._onCopyCut( evt, data ) ); + this.listenTo( editor.model, 'insertContent', ( evt, args ) => this._onInsertContent( evt, ...args ), { priority: 'high' } ); } /** @@ -50,7 +57,7 @@ export default class TableClipboard extends Plugin { * @param {Object} data Clipboard event data. */ _onCopyCut( evt, data ) { - const tableSelection = this.editor.plugins.get( 'TableSelection' ); + const tableSelection = this.editor.plugins.get( TableSelection ); if ( !tableSelection.getSelectedTableCells() ) { return; @@ -74,4 +81,221 @@ export default class TableClipboard extends Plugin { method: evt.name } ); } + + /** + * Overrides default {@link module:engine/model/model~Model#insertContent `model.insertContent()`} method to handle pasting table inside + * selected table fragment. + * + * Depending on selected table fragment: + * - If a selected table fragment is smaller than paste table it will crop pasted table to match dimensions. + * - If dimensions are equal it will replace selected table fragment with a pasted table contents. + * + * @private + * @param evt + * @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/item~Item} content The content to insert. + * @param {module:engine/model/selection~Selectable} [selectable=model.document.selection] + * The selection into which the content should be inserted. If not provided the current model document selection will be used. + */ + _onInsertContent( evt, content, selectable ) { + if ( selectable && !selectable.is( 'documentSelection' ) ) { + return; + } + + const tableSelection = this.editor.plugins.get( TableSelection ); + const selectedTableCells = tableSelection.getSelectedTableCells(); + + if ( !selectedTableCells ) { + return; + } + + // We might need to crop table before inserting so reference might change. + let pastedTable = getTableIfOnlyTableInContent( content ); + + if ( !pastedTable ) { + return; + } + + // Override default model.insertContent() handling at this point. + evt.stop(); + + // Currently not handled. See: https://github.com/ckeditor/ckeditor5/issues/6121. + if ( selectedTableCells.length === 1 ) { + // @if CK_DEBUG // console.log( 'NOT IMPLEMENTED YET: Single table cell is selected.' ); + + return; + } + + const tableUtils = this.editor.plugins.get( TableUtils ); + + // Currently not handled. The selected table content should be trimmed to a rectangular selection. + // See: https://github.com/ckeditor/ckeditor5/issues/6122. + if ( !isSelectionRectangular( selectedTableCells, tableUtils ) ) { + // @if CK_DEBUG // console.log( 'NOT IMPLEMENTED YET: Selection is not rectangular (non-mergeable).' ); + + return; + } + + const { last: lastColumnOfSelection, first: firstColumnOfSelection } = getColumnIndexes( selectedTableCells ); + const { first: firstRowOfSelection, last: lastRowOfSelection } = getRowIndexes( selectedTableCells ); + + const selectionHeight = lastRowOfSelection - firstRowOfSelection + 1; + const selectionWidth = lastColumnOfSelection - firstColumnOfSelection + 1; + + const pasteHeight = tableUtils.getRows( pastedTable ); + const pasteWidth = tableUtils.getColumns( pastedTable ); + + // 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; + } + + const model = this.editor.model; + + model.change( writer => { + // 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 + }; + + pastedTable = cropTableToDimensions( pastedTable, cropDimensions, writer, tableUtils ); + } + + // Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location. + const pastedTableLocationMap = createLocationMap( pastedTable, selectionWidth, selectionHeight ); + + // Content table to which we insert a pasted table. + const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] ); + + const selectedTableMap = [ ...new TableWalker( selectedTable, { + startRow: firstRowOfSelection, + endRow: lastRowOfSelection, + includeSpanned: true + } ) ]; + + // Selection must be set to pasted cells (some might be removed or new created). + const cellsToSelect = []; + + // Store previous cell in order to insert a new table cells after it (if required). + let previousCellInRow; + + // Content table replace cells algorithm iterates over a selected table fragment and: + // + // - Removes existing table cells at current slot (location). + // - Inserts cell from a pasted table for a matched slots. + // + // This ensures proper table geometry after the paste + for ( const { row, column, cell, isSpanned } of selectedTableMap ) { + if ( column === 0 ) { + previousCellInRow = null; + } + + // Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785. + if ( column < firstColumnOfSelection || column > lastColumnOfSelection ) { + // Only update the previousCellInRow for non-spanned slots. + if ( !isSpanned ) { + previousCellInRow = cell; + } + + continue; + } + + // If the slot is occupied by a cell in a selected table - remove it. + // The slot of this cell will be either: + // - Replaced by a pasted table cell. + // - Spanned by a previously pasted table cell. + if ( !isSpanned ) { + writer.remove( cell ); + } + + // Map current table slot location to an pasted table slot location. + const pastedCell = pastedTableLocationMap[ row - firstRowOfSelection ][ column - firstColumnOfSelection ]; + + // 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 ) { + continue; + } + + // Clone cell to insert (to duplicate its attributes and children). + // Cloning is required to support repeating pasted table content when inserting to a bigger selection. + const cellToInsert = pastedCell._clone( true ); + + let insertPosition; + + if ( !previousCellInRow ) { + insertPosition = writer.createPositionAt( selectedTable.getChild( row ), 0 ); + } else { + insertPosition = writer.createPositionAfter( previousCellInRow ); + } + + writer.insert( cellToInsert, insertPosition ); + cellsToSelect.push( cellToInsert ); + previousCellInRow = cellToInsert; + } + + writer.setSelection( cellsToSelect.map( cell => writer.createRangeOn( cell ) ) ); + } ); + } +} + +function getTableIfOnlyTableInContent( content ) { + // Table passed directly. + if ( content.is( 'table' ) ) { + return content; + } + + // We do not support mixed content when pasting table into table. + // See: https://github.com/ckeditor/ckeditor5/issues/6817. + if ( content.childCount != 1 || !content.getChild( 0 ).is( 'table' ) ) { + return null; + } + + return content.getChild( 0 ); +} + +// Returns two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location. +// +// At given row & column location it might be one of: +// +// * cell - cell from pasted table anchored at this location. +// * null - if no cell is anchored at this location. +// +// For instance, from a table below: +// +// +----+----+----+----+ +// | 00 | 01 | 02 | 03 | +// + +----+----+----+ +// | | 11 | 13 | +// +----+ +----+ +// | 20 | | 23 | +// +----+----+----+----+ +// +// The method will return an array (numbers represents cell element): +// +// const map = [ +// [ '00', '01', '02', '03' ], +// [ null, '11', null, '13' ], +// [ '20', null, null, '23' ] +// ] +// +// This allows for a quick access to table at give row & column. For instance to access table cell "13" from pasted table call: +// +// const cell = map[ 1 ][ 3 ] +// +function createLocationMap( table, width, height ) { + // Create height x width (row x column) two-dimensional table to store cells. + const map = new Array( height ).fill( null ) + .map( () => new Array( width ).fill( null ) ); + + for ( const { column, row, cell } of new TableWalker( table ) ) { + map[ row ][ column ] = cell; + } + + return map; } diff --git a/packages/ckeditor5-table/src/tableselection.js b/packages/ckeditor5-table/src/tableselection.js index 39aafa14817..8cd9e92ffd8 100644 --- a/packages/ckeditor5-table/src/tableselection.js +++ b/packages/ckeditor5-table/src/tableselection.js @@ -13,12 +13,9 @@ import first from '@ckeditor/ckeditor5-utils/src/first'; import TableWalker from './tablewalker'; import TableUtils from './tableutils'; import MouseEventsObserver from './tableselection/mouseeventsobserver'; -import { - getSelectedTableCells, - getTableCellsContainingSelection -} from './utils'; +import { getColumnIndexes, getRowIndexes, getSelectedTableCells, getTableCellsContainingSelection } from './utils'; import { findAncestor } from './commands/utils'; -import cropTable from './tableselection/croptable'; +import { cropTableToDimensions } from './tableselection/croptable'; import '../theme/tableselection.css'; @@ -99,7 +96,20 @@ export default class TableSelection extends Plugin { return this.editor.model.change( writer => { const documentFragment = writer.createDocumentFragment(); - const table = cropTable( selectedCells, this.editor.plugins.get( 'TableUtils' ), writer ); + + const { first: startColumn, last: endColumn } = getColumnIndexes( selectedCells ); + const { first: startRow, last: endRow } = getRowIndexes( selectedCells ); + + const sourceTable = findAncestor( 'table', selectedCells[ 0 ] ); + + const cropDimensions = { + startRow, + startColumn, + endRow, + endColumn + }; + + const table = cropTableToDimensions( sourceTable, cropDimensions, writer, this.editor.plugins.get( 'TableUtils' ) ); writer.insert( table, documentFragment, 0 ); diff --git a/packages/ckeditor5-table/src/tableselection/croptable.js b/packages/ckeditor5-table/src/tableselection/croptable.js index 656c9b13402..241fc1d40b1 100644 --- a/packages/ckeditor5-table/src/tableselection/croptable.js +++ b/packages/ckeditor5-table/src/tableselection/croptable.js @@ -7,146 +7,138 @@ * @module table/tableselection/croptable */ -import { findAncestor } from '../commands/utils'; +import { createEmptyTableCell, updateNumericAttribute } from '../commands/utils'; +import TableWalker from '../tablewalker'; /** - * Returns a cropped table from the selected table cells. - * - * This function is to be used with the table selection. + * Returns a cropped table according to given dimensions. + + * To return a cropped table that starts at first row and first column and end in third row and column: * - * tableSelection.startSelectingFrom( startCell ) - * tableSelection.setSelectingFrom( endCell ) + * const croppedTable = cropTableToDimensions( table, { + * startRow: 1, + * endRow: 1, + * startColumn: 3, + * endColumn: 3 + * }, tableUtils, writer ); * - * const croppedTable = cropTable( tableSelection.getSelectedTableCells() ); + * Calling the code above for the table below: * - * **Note**: This function is also used by {@link module:table/tableselection~TableSelection#getSelectionAsFragment}. + * 0 1 2 3 4 0 1 2 + * ┌───┬───┬───┬───┬───┐ + * 0 │ a │ b │ c │ d │ e │ + * ├───┴───┤ ├───┴───┤ ┌───┬───┬───┐ + * 1 │ f │ │ g │ │ │ │ g │ 0 + * ├───┬───┴───┼───┬───┤ will return: ├───┴───┼───┤ + * 2 │ h │ i │ j │ k │ │ i │ j │ 1 + * ├───┤ ├───┤ │ │ ├───┤ + * 3 │ l │ │ m │ │ │ │ m │ 2 + * ├───┼───┬───┤ ├───┤ └───────┴───┘ + * 4 │ n │ o │ p │ │ q │ + * └───┴───┴───┴───┴───┘ * - * @param {Iterable.} selectedTableCellsIterator - * @param {module:table/tableutils~TableUtils} tableUtils + * @param {module:engine/model/element~Element} sourceTable + * @param {Object} cropDimensions + * @param {Number} cropDimensions.startRow + * @param {Number} cropDimensions.startColumn + * @param {Number} cropDimensions.endRow + * @param {Number} cropDimensions.endColumn * @param {module:engine/model/writer~Writer} writer + * @param {module:table/tableutils~TableUtils} tableUtils * @returns {module:engine/model/element~Element} */ -export default function cropTable( selectedTableCellsIterator, tableUtils, writer ) { - const selectedTableCells = Array.from( selectedTableCellsIterator ); - const startElement = selectedTableCells[ 0 ]; - const endElement = selectedTableCells[ selectedTableCells.length - 1 ]; - - const { row: startRow, column: startColumn } = tableUtils.getCellLocation( startElement ); - - const tableCopy = makeTableCopy( selectedTableCells, startColumn, writer, tableUtils ); - - const { row: endRow, column: endColumn } = tableUtils.getCellLocation( endElement ); - const selectionWidth = endColumn - startColumn + 1; - const selectionHeight = endRow - startRow + 1; +export function cropTableToDimensions( sourceTable, cropDimensions, writer, tableUtils ) { + const { startRow, startColumn, endRow, endColumn } = cropDimensions; - trimTable( tableCopy, selectionWidth, selectionHeight, writer, tableUtils ); + // Create empty table with empty rows equal to crop height. + const croppedTable = writer.createElement( 'table' ); + const cropHeight = endRow - startRow + 1; - const sourceTable = findAncestor( 'table', startElement ); - addHeadingsToTableCopy( tableCopy, sourceTable, startRow, startColumn, writer ); + for ( let i = 0; i < cropHeight; i++ ) { + writer.insertElement( 'tableRow', croppedTable, 'end' ); + } - return tableCopy; -} + const tableMap = [ ...new TableWalker( sourceTable, { startRow, endRow, includeSpanned: true } ) ]; -// Creates a table copy from a selected table cells. -// -// It fills "gaps" in copied table - ie when cell outside copied range was spanning over selection. -function makeTableCopy( selectedTableCells, startColumn, writer, tableUtils ) { - const tableCopy = writer.createElement( 'table' ); + // Iterate over source table slots (including empty - spanned - ones). + for ( const { row: sourceRow, column: sourceColumn, cell: tableCell, isSpanned } of tableMap ) { + // Skip slots outside the cropped area. + // Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785. + if ( sourceColumn < startColumn || sourceColumn > endColumn ) { + continue; + } - const rowToCopyMap = new Map(); - const copyToOriginalColumnMap = new Map(); + // Row index in cropped table. + const rowInCroppedTable = sourceRow - startRow; + const row = croppedTable.getChild( rowInCroppedTable ); - for ( const tableCell of selectedTableCells ) { - const row = findAncestor( 'tableRow', tableCell ); + // For empty slots: fill the gap with empty table cell. + if ( isSpanned ) { + // TODO: Remove table utils usage. See: https://github.com/ckeditor/ckeditor5/issues/6785. + const { row: anchorRow, column: anchorColumn } = tableUtils.getCellLocation( tableCell ); - if ( !rowToCopyMap.has( row ) ) { - const rowCopy = row._clone(); - writer.append( rowCopy, tableCopy ); - rowToCopyMap.set( row, rowCopy ); + // But fill the gap only if the spanning cell is anchored outside cropped area. + // In the table from method jsdoc those cells are: "c" & "f". + if ( anchorRow < startRow || anchorColumn < startColumn ) { + createEmptyTableCell( writer, writer.createPositionAt( row, 'end' ) ); + } } + // Otherwise clone the cell with all children and trim if it exceeds cropped area. + else { + const tableCellCopy = tableCell._clone( true ); - const tableCellCopy = tableCell._clone( true ); - const { column } = tableUtils.getCellLocation( tableCell ); - - copyToOriginalColumnMap.set( tableCellCopy, column ); + writer.append( tableCellCopy, row ); - writer.append( tableCellCopy, rowToCopyMap.get( row ) ); + // Trim table if it exceeds cropped area. + // In the table from method jsdoc those cells are: "g" & "m". + trimTableCellIfNeeded( tableCellCopy, sourceRow, sourceColumn, endRow, endColumn, writer ); + } } - addMissingTableCells( tableCopy, startColumn, copyToOriginalColumnMap, writer, tableUtils ); + // Adjust heading rows & columns in cropped table if crop selection includes headings parts. + addHeadingsToCroppedTable( croppedTable, sourceTable, startRow, startColumn, writer ); - return tableCopy; + return croppedTable; } -// Fills gaps for spanned cell from outside the selection range. -function addMissingTableCells( tableCopy, startColumn, copyToOriginalColumnMap, writer, tableUtils ) { - for ( const row of tableCopy.getChildren() ) { - for ( const tableCell of Array.from( row.getChildren() ) ) { - const { column } = tableUtils.getCellLocation( tableCell ); - - const originalColumn = copyToOriginalColumnMap.get( tableCell ); - const shiftedColumn = originalColumn - startColumn; +// Adjusts table cell dimensions to not exceed limit row and column. +// +// If table cell span to a column (or row) that is after a limit column (or row) trim colspan (or rowspan) +// so the table cell will fit in a cropped area. +function trimTableCellIfNeeded( tableCell, cellRow, cellColumn, limitRow, limitColumn, writer ) { + const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); + const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 ); - if ( column !== shiftedColumn ) { - for ( let i = 0; i < shiftedColumn - column; i++ ) { - const prepCell = writer.createElement( 'tableCell' ); - writer.insert( prepCell, writer.createPositionBefore( tableCell ) ); + const endColumn = cellColumn + colspan - 1; - const paragraph = writer.createElement( 'paragraph' ); + if ( endColumn > limitColumn ) { + const trimmedSpan = limitColumn - cellColumn + 1; - writer.insert( paragraph, prepCell, 0 ); - writer.insertText( '', paragraph, 0 ); - } - } - } + updateNumericAttribute( 'colspan', trimmedSpan, tableCell, writer, 1 ); } -} -// Trims table to a given dimensions. -function trimTable( table, width, height, writer, tableUtils ) { - for ( const row of table.getChildren() ) { - for ( const tableCell of row.getChildren() ) { - const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); - const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 ); + const endRow = cellRow + rowspan - 1; - const { row, column } = tableUtils.getCellLocation( tableCell ); + if ( endRow > limitRow ) { + const trimmedSpan = limitRow - cellRow + 1; - if ( column + colspan > width ) { - const newSpan = width - column; - - if ( newSpan > 1 ) { - writer.setAttribute( 'colspan', newSpan, tableCell ); - } else { - writer.removeAttribute( 'colspan', tableCell ); - } - } - - if ( row + rowspan > height ) { - const newSpan = height - row; - - if ( newSpan > 1 ) { - writer.setAttribute( 'rowspan', newSpan, tableCell ); - } else { - writer.removeAttribute( 'rowspan', tableCell ); - } - } - } + updateNumericAttribute( 'rowspan', trimmedSpan, tableCell, writer, 1 ); } } -// Sets proper heading attributes to copied table. -function addHeadingsToTableCopy( tableCopy, sourceTable, startRow, startColumn, writer ) { +// Sets proper heading attributes to a cropped table. +function addHeadingsToCroppedTable( croppedTable, sourceTable, startRow, startColumn, writer ) { const headingRows = parseInt( sourceTable.getAttribute( 'headingRows' ) || 0 ); if ( headingRows > 0 ) { - const copiedRows = headingRows - startRow; - writer.setAttribute( 'headingRows', copiedRows, tableCopy ); + const headingRowsInCrop = headingRows - startRow; + updateNumericAttribute( 'headingRows', headingRowsInCrop, croppedTable, writer, 0 ); } const headingColumns = parseInt( sourceTable.getAttribute( 'headingColumns' ) || 0 ); if ( headingColumns > 0 ) { - const copiedColumns = headingColumns - startColumn; - writer.setAttribute( 'headingColumns', copiedColumns, tableCopy ); + const headingColumnsInCrop = headingColumns - startColumn; + updateNumericAttribute( 'headingColumns', headingColumnsInCrop, croppedTable, writer, 0 ); } } diff --git a/packages/ckeditor5-table/src/utils.js b/packages/ckeditor5-table/src/utils.js index 2fefc1e2d7f..d6cdae0e439 100644 --- a/packages/ckeditor5-table/src/utils.js +++ b/packages/ckeditor5-table/src/utils.js @@ -179,6 +179,74 @@ export function getColumnIndexes( tableCells ) { return getFirstLastIndexesObject( indexes ); } +/** + * Checks if the selection contains cells that do not exceed rectangular selection. + * + * In a table below: + * + * ┌───┬───┬───┬───┐ + * │ a │ b │ c │ d │ + * ├───┴───┼───┤ │ + * │ e │ f │ │ + * ├ ├───┼───┤ + * │ │ g │ h │ + * └───────┴───┴───┘ + * + * Valid selections are these which create a solid rectangle (without gaps), such as: + * - a, b (two horizontal cells) + * - c, f (two vertical cells) + * - a, b, e (cell "e" spans over four cells) + * - c, d, f (cell d spans over a cell in the row below) + * + * While an invalid selection would be: + * - a, c (the unselected cell "b" creates a gap) + * - f, g, h (cell "d" spans over a cell from the row of "f" cell - thus creates a gap) + * + * @param {Array.} selectedTableCells + * @param {module:table/tableutils~TableUtils} tableUtils + * @returns {Boolean} + */ +export function isSelectionRectangular( selectedTableCells, tableUtils ) { + if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) { + return false; + } + + // A valid selection is a fully occupied rectangle composed of table cells. + // Below we will calculate the area of a selected table cells and the area of valid selection. + // The area of a valid selection is defined by top-left and bottom-right cells. + const rows = new Set(); + const columns = new Set(); + + let areaOfSelectedCells = 0; + + for ( const tableCell of selectedTableCells ) { + const { row, column } = tableUtils.getCellLocation( tableCell ); + const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 ); + const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); + + // Record row & column indexes of current cell. + rows.add( row ); + columns.add( column ); + + // For cells that spans over multiple rows add also the last row that this cell spans over. + if ( rowspan > 1 ) { + rows.add( row + rowspan - 1 ); + } + + // For cells that spans over multiple columns add also the last column that this cell spans over. + if ( colspan > 1 ) { + columns.add( column + colspan - 1 ); + } + + areaOfSelectedCells += ( rowspan * colspan ); + } + + // We can only merge table cells that are in adjacent rows... + const areaOfValidSelection = getBiggestRectangleArea( rows, columns ); + + return areaOfValidSelection == areaOfSelectedCells; +} + // 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 ); @@ -203,3 +271,61 @@ function compareRangeOrder( rangeA, rangeB ) { // b. Collapsed range on the same position (allowed by model but should not happen). return posA.isBefore( posB ) ? -1 : 1; } + +// Calculates the area of a maximum rectangle that can span over the provided row & column indexes. +// +// @param {Array.} rows +// @param {Array.} columns +// @returns {Number} +function getBiggestRectangleArea( rows, columns ) { + const rowsIndexes = Array.from( rows.values() ); + const columnIndexes = Array.from( columns.values() ); + + const lastRow = Math.max( ...rowsIndexes ); + const firstRow = Math.min( ...rowsIndexes ); + const lastColumn = Math.max( ...columnIndexes ); + const firstColumn = Math.min( ...columnIndexes ); + + return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 ); +} + +// Checks if the selection does not mix a header (column or row) with other cells. +// +// For instance, in the table below valid selections consist of cells with the same letter only. +// So, a-a (same heading row and column) or d-d (body cells) are valid while c-d or a-b are not. +// +// header columns +// ↓ ↓ +// ┌───┬───┬───┬───┐ +// │ a │ a │ b │ b │ ← header row +// ├───┼───┼───┼───┤ +// │ c │ c │ d │ d │ +// ├───┼───┼───┼───┤ +// │ c │ c │ d │ d │ +// └───┴───┴───┴───┘ +// +function areCellInTheSameTableSection( tableCells ) { + const table = findAncestor( 'table', tableCells[ 0 ] ); + + const rowIndexes = getRowIndexes( tableCells ); + const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 ); + + // Calculating row indexes is a bit cheaper so if this check fails we can't merge. + if ( !areIndexesInSameSection( rowIndexes, headingRows ) ) { + return false; + } + + const headingColumns = parseInt( table.getAttribute( 'headingColumns' ) || 0 ); + const columnIndexes = getColumnIndexes( tableCells ); + + // Similarly cells must be in same column section. + return areIndexesInSameSection( columnIndexes, headingColumns ); +} + +// Unified check if table rows/columns indexes are in the same heading/body section. +function areIndexesInSameSection( { first, last }, headingSectionSize ) { + const firstCellIsInHeading = first < headingSectionSize; + const lastCellIsInHeading = last < headingSectionSize; + + return firstCellIsInHeading === lastCellIsInHeading; +} diff --git a/packages/ckeditor5-table/tests/manual/tablemocking.html b/packages/ckeditor5-table/tests/manual/tablemocking.html index 5ad6dc06011..5d3f483a534 100644 --- a/packages/ckeditor5-table/tests/manual/tablemocking.html +++ b/packages/ckeditor5-table/tests/manual/tablemocking.html @@ -43,6 +43,7 @@ + diff --git a/packages/ckeditor5-table/tests/manual/tablemocking.js b/packages/ckeditor5-table/tests/manual/tablemocking.js index be5ad3e006c..23a7afc64c4 100644 --- a/packages/ckeditor5-table/tests/manual/tablemocking.js +++ b/packages/ckeditor5-table/tests/manual/tablemocking.js @@ -58,10 +58,13 @@ ClassicEditor return; } + const useLetters = document.getElementById( 'use-letters' ).checked; + editor.model.change( writer => { for ( const { row, column, cell } of new TableWalker( table ) ) { const selection = editor.model.createSelection( cell, 'in' ); - editor.model.insertContent( writer.createText( `${ row }${ column }` ), selection ); + + editor.model.insertContent( writer.createText( createCellText( row, column, useLetters ) ), selection ); } } ); @@ -127,6 +130,13 @@ ClassicEditor function updateInputStatus( message = '' ) { document.getElementById( 'input-status' ).innerText = message; } + + function createCellText( row, column, useLetters ) { + const rowLabel = useLetters ? String.fromCharCode( row + 'a'.charCodeAt( 0 ) ) : row; + const columnLabel = useLetters ? String.fromCharCode( column + 'a'.charCodeAt( 0 ) ) : column; + + return `${ rowLabel }${ columnLabel }`; + } } ) .catch( err => { console.error( err.stack ); diff --git a/packages/ckeditor5-table/tests/tableclipboard-copy-cut.js b/packages/ckeditor5-table/tests/tableclipboard-copy-cut.js new file mode 100644 index 00000000000..a52fb406f12 --- /dev/null +++ b/packages/ckeditor5-table/tests/tableclipboard-copy-cut.js @@ -0,0 +1,422 @@ +/** + * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals document */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; +import { modelTable, viewTable } from './_utils/utils'; + +import TableEditing from '../src/tableediting'; +import TableClipboard from '../src/tableclipboard'; + +describe( 'table clipboard', () => { + let editor, model, modelRoot, tableSelection, viewDocument, element; + + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor.create( element, { + plugins: [ TableEditing, TableClipboard, Paragraph, Clipboard ] + } ); + + model = editor.model; + modelRoot = model.document.getRoot(); + viewDocument = editor.editing.view.document; + tableSelection = editor.plugins.get( 'TableSelection' ); + + setModelData( model, modelTable( [ + [ '00[]', '01', '02' ], + [ '10', '11', '12' ], + [ '20', '21', '22' ] + ] ) ); + } ); + + afterEach( async () => { + await editor.destroy(); + + element.remove(); + } ); + + describe( 'Clipboard integration - copy', () => { + it( 'should do nothing for normal selection in table', () => { + const dataTransferMock = createDataTransfer(); + const spy = sinon.spy(); + + viewDocument.on( 'clipboardOutput', spy ); + + viewDocument.fire( 'copy', { + dataTransfer: dataTransferMock, + preventDefault: sinon.spy() + } ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should copy selected table cells as a standalone table', () => { + const preventDefaultSpy = sinon.spy(); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 1 ] ), + modelRoot.getNodeByPath( [ 0, 1, 2 ] ) + ); + + const data = { + dataTransfer: createDataTransfer(), + preventDefault: preventDefaultSpy + }; + viewDocument.fire( 'copy', data ); + + sinon.assert.calledOnce( preventDefaultSpy ); + expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( viewTable( [ + [ '01', '02' ], + [ '11', '12' ] + ] ) ); + } ); + + it( 'should trim selected table to a selection rectangle (inner cell with colspan, no colspan after trim)', () => { + setModelData( model, modelTable( [ + [ '00[]', '01', '02' ], + [ '10', { contents: '11', colspan: 2 } ], + [ '20', '21', '22' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 1 ] ) + ); + + assertClipboardContentOnMethod( 'copy', viewTable( [ + [ '00', '01' ], + [ '10', '11' ], + [ '20', '21' ] + ] ) ); + } ); + + it( 'should trim selected table to a selection rectangle (inner cell with colspan, has colspan after trim)', () => { + setModelData( model, modelTable( [ + [ '00[]', '01', '02' ], + [ { contents: '10', colspan: 3 } ], + [ '20', '21', '22' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 1 ] ) + ); + + assertClipboardContentOnMethod( 'copy', viewTable( [ + [ '00', '01' ], + [ { contents: '10', colspan: 2 } ], + [ '20', '21' ] + ] ) ); + } ); + + it( 'should trim selected table to a selection rectangle (inner cell with rowspan, no colspan after trim)', () => { + setModelData( model, modelTable( [ + [ '00[]', '01', '02' ], + [ '10', { contents: '11', rowspan: 2 }, '12' ], + [ '20', '21', '22' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 2 ] ) + ); + + assertClipboardContentOnMethod( 'copy', viewTable( [ + [ '00', '01', '02' ], + [ '10', '11', '12' ] + ] ) ); + } ); + + it( 'should trim selected table to a selection rectangle (inner cell with rowspan, has rowspan after trim)', () => { + setModelData( model, modelTable( [ + [ '00[]', { contents: '01', rowspan: 3 }, '02' ], + [ '10', '12' ], + [ '20', '22' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + assertClipboardContentOnMethod( 'copy', viewTable( [ + [ '00', { contents: '01', rowspan: 2 }, '02' ], + [ '10', '12' ] + ] ) ); + } ); + + it( 'should prepend spanned columns with empty cells (outside cell with colspan)', () => { + setModelData( model, modelTable( [ + [ '00[]', '01', '02' ], + [ { contents: '10', colspan: 2 }, '12' ], + [ '20', '21', '22' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 1 ] ), + modelRoot.getNodeByPath( [ 0, 2, 2 ] ) + ); + + assertClipboardContentOnMethod( 'copy', viewTable( [ + [ '01', '02' ], + [ ' ', '12' ], + [ '21', '22' ] + ] ) ); + } ); + + it( 'should prepend spanned columns with empty cells (outside cell with rowspan)', () => { + setModelData( model, modelTable( [ + [ '00', { contents: '01', rowspan: 2 }, '02' ], + [ '10', '12' ], + [ '20', '21', '22' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 2 ] ) + ); + + assertClipboardContentOnMethod( 'copy', viewTable( [ + [ '10', ' ', '12' ], + [ '20', '21', '22' ] + ] ) ); + } ); + + it( 'should fix selected table to a selection rectangle (hardcore case)', () => { + // This test check how previous simple rules run together (mixed prepending and trimming). + // In the example below a selection is set from cell "21" to "77" + // + // Input table: Copied table: + // + // +----+----+----+----+----+----+----+----+----+ + // | 00 | 01 | 02 | 03 | 04 | 06 | 07 | 08 | + // +----+----+ +----+ +----+----+----+ + // | 10 | 11 | | 13 | | 16 | 17 | 18 | + // +----+----+ +----+ +----+----+----+ +----+----+----+----+----+----+----+ + // | 20 | 21 | | 23 | | 26 | | 21 | | 23 | | | 26 | + // +----+----+ +----+ +----+----+----+ +----+----+----+----+----+----+----+ + // | 30 | 31 | | 33 | | 36 | 37 | | 31 | | 33 | | | 36 | 37 | + // +----+----+----+----+ +----+----+----+ +----+----+----+----+----+----+----+ + // | 40 | | 46 | 47 | 48 | | | | | | | 46 | 47 | + // +----+----+----+----+ +----+----+----+ ==> +----+----+----+----+----+----+----+ + // | 50 | 51 | 52 | 53 | | 56 | 57 | 58 | | 51 | 52 | 53 | | | 56 | 57 | + // +----+----+----+----+ + +----+----+ +----+----+----+----+----+ +----+ + // | 60 | 61 | | | 67 | 68 | | 61 | | | | 67 | + // +----+----+----+----+----+----+ +----+----+ +----+----+----+----+----+ +----+ + // | 70 | 71 | 72 | 73 | 74 | 75 | | 77 | 78 | | 71 | 72 | 73 | 74 | 75 | | 77 | + // +----+ +----+----+----+----+ +----+----+ +----+----+----+----+----+----+----+ + // | 80 | | 82 | 83 | 84 | 85 | | 87 | 88 | + // +----+----+----+----+----+----+----+----+----+ + // + setModelData( model, modelTable( [ + [ '00', '01', { contents: '02', rowspan: 4 }, '03', { contents: '04', colspan: 2, rowspan: 7 }, '06', '07', '08' ], + [ '10', '11', '13', '16', '17', '18' ], + [ '20', '21', '23', { contents: '26', colspan: 3 } ], + [ '30', '31', '33', '36', { contents: '37', colspan: 2 } ], + [ { contents: '40', colspan: 4 }, '46', '47', '48' ], + [ '50', '51', '52', '53', { contents: '56', rowspan: 4 }, '57', '58' ], + [ '60', { contents: '61', colspan: 3 }, '67', '68' ], + [ '70', { contents: '71', rowspan: 2 }, '72', '73', '74', '75', '77', '78' ], + [ '80', '82', '83', '84', '85', '87', '88' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 2, 1 ] ), + modelRoot.getNodeByPath( [ 0, 7, 6 ] ) + ); + + assertClipboardContentOnMethod( 'copy', viewTable( [ + [ '21', ' ', '23', ' ', ' ', { contents: '26', colspan: 2 } ], + [ '31', ' ', '33', ' ', ' ', '36', '37' ], + [ ' ', ' ', ' ', ' ', ' ', '46', '47' ], + [ '51', '52', '53', ' ', ' ', { contents: '56', rowspan: 3 }, '57' ], + [ { contents: '61', colspan: 3 }, ' ', ' ', '67' ], + [ '71', '72', '73', '74', '75', '77' ] + ] ) ); + } ); + + it( 'should update table heading attributes (selection with headings)', () => { + setModelData( model, modelTable( [ + [ '00', '01', '02', '03', '04' ], + [ '10', '11', '12', '13', '14' ], + [ '20', '21', '22', '23', '24' ], + [ '30', '31', '32', '33', '34' ], + [ '40', '41', '42', '43', '44' ] + ], { headingRows: 3, headingColumns: 2 } ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 1 ] ), + modelRoot.getNodeByPath( [ 0, 3, 3 ] ) + ); + + assertClipboardContentOnMethod( 'copy', viewTable( [ + [ '11', '12', '13' ], + [ '21', '22', '23' ], + [ { contents: '31', isHeading: true }, '32', '33' ] // TODO: bug in viewTable + ], { headingRows: 2, headingColumns: 1 } ) ); + } ); + + it( 'should update table heading attributes (selection without headings)', () => { + setModelData( model, modelTable( [ + [ '00', '01', '02', '03', '04' ], + [ '10', '11', '12', '13', '14' ], + [ '20', '21', '22', '23', '24' ], + [ '30', '31', '32', '33', '34' ], + [ '40', '41', '42', '43', '44' ] + ], { headingRows: 3, headingColumns: 2 } ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 3, 2 ] ), + modelRoot.getNodeByPath( [ 0, 4, 4 ] ) + ); + + assertClipboardContentOnMethod( 'copy', viewTable( [ + [ '32', '33', '34' ], + [ '42', '43', '44' ] + ] ) ); + } ); + } ); + + describe( 'Clipboard integration - cut', () => { + it( 'should not block clipboardOutput if no multi-cell selection', () => { + setModelData( model, modelTable( [ + [ '[00]', '01', '02' ], + [ '10', '11', '12' ], + [ '20', '21', '22' ] + ] ) ); + + const dataTransferMock = createDataTransfer(); + + viewDocument.fire( 'cut', { + dataTransfer: dataTransferMock, + preventDefault: sinon.spy() + } ); + + expect( dataTransferMock.getData( 'text/html' ) ).to.equal( '00' ); + } ); + + it( 'should be preventable', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + viewDocument.on( 'clipboardOutput', evt => evt.stop(), { priority: 'high' } ); + + viewDocument.fire( 'cut', { + dataTransfer: createDataTransfer(), + preventDefault: sinon.spy() + } ); + + assertEqualMarkup( getModelData( model ), modelTable( [ + [ { contents: '00', isSelected: true }, { contents: '01', isSelected: true }, '02' ], + [ { contents: '10', isSelected: true }, { contents: '11', isSelected: true }, '12' ], + [ '20', '21', '22' ] + ] ) ); + } ); + + it( 'is clears selected table cells', () => { + const spy = sinon.spy(); + + viewDocument.on( 'clipboardOutput', spy ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + viewDocument.fire( 'cut', { + dataTransfer: createDataTransfer(), + preventDefault: sinon.spy() + } ); + + assertEqualMarkup( getModelData( model ), modelTable( [ + [ '', '', '02' ], + [ '', '[]', '12' ], + [ '20', '21', '22' ] + ] ) ); + } ); + + it( 'should copy selected table cells as a standalone table', () => { + const preventDefaultSpy = sinon.spy(); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 1 ] ), + modelRoot.getNodeByPath( [ 0, 1, 2 ] ) + ); + + const data = { + dataTransfer: createDataTransfer(), + preventDefault: preventDefaultSpy + }; + viewDocument.fire( 'cut', data ); + + sinon.assert.calledOnce( preventDefaultSpy ); + expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( viewTable( [ + [ '01', '02' ], + [ '11', '12' ] + ] ) ); + } ); + + it( 'should be disabled in a readonly mode', () => { + const preventDefaultStub = sinon.stub(); + + editor.isReadOnly = true; + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 1 ] ), + modelRoot.getNodeByPath( [ 0, 1, 2 ] ) + ); + + const data = { + dataTransfer: createDataTransfer(), + preventDefault: preventDefaultStub + }; + viewDocument.fire( 'cut', data ); + + editor.isReadOnly = false; + + expect( data.dataTransfer.getData( 'text/html' ) ).to.be.undefined; + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02' ], + [ '10', '11', '12' ], + [ '20', '21', '22' ] + ] ) ); + + sinon.assert.calledOnce( preventDefaultStub ); + } ); + } ); + + function assertClipboardContentOnMethod( method, expectedViewTable ) { + const data = { + dataTransfer: createDataTransfer(), + preventDefault: sinon.spy() + }; + viewDocument.fire( method, data ); + + expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( expectedViewTable ); + } + + function createDataTransfer() { + const store = new Map(); + + return { + setData( type, data ) { + store.set( type, data ); + }, + + getData( type ) { + return store.get( type ); + } + }; + } +} ); diff --git a/packages/ckeditor5-table/tests/tableclipboard-paste.js b/packages/ckeditor5-table/tests/tableclipboard-paste.js new file mode 100644 index 00000000000..8772ffa4976 --- /dev/null +++ b/packages/ckeditor5-table/tests/tableclipboard-paste.js @@ -0,0 +1,1715 @@ +/** + * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals document console */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; +import HorizontalLineEditing from '@ckeditor/ckeditor5-horizontal-line/src/horizontallineediting'; +import ImageCaptionEditing from '@ckeditor/ckeditor5-image/src/imagecaption/imagecaptionediting'; +import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting'; +import ListEditing from '@ckeditor/ckeditor5-list/src/listediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; +import { assertSelectedCells, modelTable, viewTable } from './_utils/utils'; + +import TableEditing from '../src/tableediting'; +import TableCellPropertiesEditing from '../src/tablecellproperties/tablecellpropertiesediting'; +import TableWalker from '../src/tablewalker'; + +import TableClipboard from '../src/tableclipboard'; + +describe( 'table clipboard', () => { + let editor, model, modelRoot, tableSelection, viewDocument, element; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + } ); + + afterEach( async () => { + await editor.destroy(); + + element.remove(); + } ); + + describe( 'Clipboard integration - paste (selection scenarios)', () => { + beforeEach( async () => { + await createEditor(); + + setModelData( model, modelTable( [ + [ '00[]', '01', '02', '03' ], + [ '10', '11', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + } ); + + it( 'should be disabled in a readonly mode', () => { + editor.isReadOnly = true; + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + const data = pasteTable( [ + [ 'aa', 'ab' ], + [ 'ba', 'bb' ] + ] ); + + editor.isReadOnly = false; + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03' ], + [ '10', '11', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + sinon.assert.calledOnce( data.preventDefault ); + } ); + + it( 'should allow normal paste if no table cells are selected', () => { + const data = { + dataTransfer: createDataTransfer(), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + data.dataTransfer.setData( 'text/html', '

foo

' ); + viewDocument.fire( 'paste', data ); + + editor.isReadOnly = false; + + assertEqualMarkup( getModelData( model ), modelTable( [ + [ '00foo[]', '01', '02', '03' ], + [ '10', '11', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + } ); + + it( 'should not alter model.insertContent if selectable is different from document selection', () => { + model.change( writer => { + writer.setSelection( modelRoot.getNodeByPath( [ 0, 0, 0 ] ), 0 ); + + const selectedTableCells = model.createSelection( [ + model.createRangeOn( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ), + model.createRangeOn( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ), + model.createRangeOn( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ), + model.createRangeOn( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) ) + ] ); + + const tableToInsert = editor.plugins.get( 'TableUtils' ).createTable( writer, 2, 2 ); + + for ( const { cell } of new TableWalker( tableToInsert ) ) { + writer.insertText( 'foo', cell.getChild( 0 ), 0 ); + } + + model.insertContent( tableToInsert, selectedTableCells ); + } ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '', '', '02', '03' ], + [ '', '', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + } ); + + it( 'should not alter model.insertContent if no table pasted', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + const data = { + dataTransfer: createDataTransfer(), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + data.dataTransfer.setData( 'text/html', '

foo

' ); + viewDocument.fire( 'paste', data ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'foo', '', '02', '03' ], + [ '', '', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + } ); + + it( 'should not alter model.insertContent if mixed content is pasted (table + paragraph)', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + const table = viewTable( [ + [ 'aa', 'ab' ], + [ 'ba', 'bb' ] ] ); + + const data = { + dataTransfer: createDataTransfer(), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + data.dataTransfer.setData( 'text/html', `${ table }

foo

` ); + viewDocument.fire( 'paste', data ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'foo', '', '02', '03' ], + [ '', '', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + } ); + + it( 'should not alter model.insertContent if mixed content is pasted (paragraph + table)', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + const table = viewTable( [ + [ 'aa', 'ab' ], + [ 'ba', 'bb' ] ] ); + + const data = { + dataTransfer: createDataTransfer(), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + data.dataTransfer.setData( 'text/html', `

foo

${ table }` ); + viewDocument.fire( 'paste', data ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'foo', '', '02', '03' ], + [ '', '', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + } ); + + it( 'should not alter model.insertContent if mixed content is pasted (table + table)', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + const table = viewTable( [ + [ 'aa', 'ab' ], + [ 'ba', 'bb' ] ] ); + + const data = { + dataTransfer: createDataTransfer(), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + data.dataTransfer.setData( 'text/html', `${ table }${ table }` ); + viewDocument.fire( 'paste', data ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '', '', '02', '03' ], + [ '', '', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + } ); + + it( 'should alter model.insertContent if selectable is a document selection', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + model.change( writer => { + const tableToInsert = editor.plugins.get( 'TableUtils' ).createTable( writer, 2, 2 ); + + for ( const { cell } of new TableWalker( tableToInsert ) ) { + writer.insertText( 'foo', cell.getChild( 0 ), 0 ); + } + + model.insertContent( tableToInsert, editor.model.document.selection ); + } ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'foo', 'foo', '02', '03' ], + [ 'foo', 'foo', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + } ); + + 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', () => { + it( 'blocks this case', () => { + setModelData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '11', '12' ], + [ '20', '21', '22' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 0, 0 ] ) + ); + + // 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( [ + [ '00', '01', '02' ], + [ '10', '11', '12' ], + [ '20', '21', '22' ] + ] ) ); + } ); + } ); + + describe( 'pasted table is equal to 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' ], + [ 'ba', 'bb' ] + ] ); + + 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' ], + [ 'ba', 'bb' ] + ] ); + + 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' ], + [ 'ba', 'bb' ] + ] ); + + 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 simple row 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( [ + [ 'aa', 'ab' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03' ], + [ '10', 'aa', 'ab', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + assertSelectedCells( model, [ + [ 0, 0, 0, 0 ], + [ 0, 1, 1, 0 ], + [ 0, 0, 0, 0 ], + [ 0, 0, 0, 0 ] + ] ); + } ); + + it( 'handles simple column paste to a simple column fragment - in the middle of a table', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 1 ] ), + modelRoot.getNodeByPath( [ 0, 2, 1 ] ) + ); + + pasteTable( [ + [ 'aa' ], + [ 'ba' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03' ], + [ '10', 'aa', '12', '13' ], + [ '20', 'ba', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + assertSelectedCells( model, [ + [ 0, 0, 0, 0 ], + [ 0, 1, 0, 0 ], + [ 0, 1, 0, 0 ], + [ 0, 0, 0, 0 ] + ] ); + } ); + + it( 'handles simple table paste to a simple table fragment - whole table selected', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 3, 3 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab', 'ac', 'ad' ], + [ 'ba', 'bb', 'bc', 'bd' ], + [ 'ca', 'cb', 'cc', 'cd' ], + [ 'da', 'db', 'dc', 'dd' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', 'ab', 'ac', 'ad' ], + [ 'ba', 'bb', 'bc', 'bd' ], + [ 'ca', 'cb', 'cc', 'cd' ], + [ 'da', 'db', 'dc', 'dd' ] + ] ) ); + + assertSelectedCells( model, [ + [ 1, 1, 1, 1 ], + [ 1, 1, 1, 1 ], + [ 1, 1, 1, 1 ], + [ 1, 1, 1, 1 ] + ] ); + } ); + } ); + + describe( 'pasted table has spans', () => { + it( 'handles pasting table that has cell with colspan', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 1 ] ), + modelRoot.getNodeByPath( [ 0, 2, 2 ] ) + ); + + pasteTable( [ + [ { colspan: 2, contents: 'aa' } ], + [ 'ba', 'bb' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03' ], + [ '10', { colspan: 2, contents: 'aa' }, '13' ], + [ '20', 'ba', 'bb', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 0, 0, 0, 0 ], + [ 0, 1, 0 ], + [ 0, 1, 1, 0 ], + [ 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( 'handles pasting table that has many cells with various colspan', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 3, 2 ] ) + ); + + pasteTable( [ + [ 'aa', { colspan: 2, contents: 'ab' } ], + [ { colspan: 3, contents: 'ba' } ], + [ 'ca', 'cb', 'cc' ], + [ { colspan: 2, contents: 'da' }, 'dc' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', { colspan: 2, contents: 'ab' }, '03' ], + [ { colspan: 3, contents: 'ba' }, '13' ], + [ 'ca', 'cb', 'cc', '23' ], + [ { colspan: 2, contents: 'da' }, 'dc', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 1, 1, 0 ], + [ 1, 0 ], + [ 1, 1, 1, 0 ], + [ 1, 1, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( 'handles pasting table that has cell with rowspan', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 1 ] ), + modelRoot.getNodeByPath( [ 0, 2, 2 ] ) + ); + + pasteTable( [ + [ { rowspan: 2, contents: 'aa' }, 'ab' ], + [ 'bb' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03' ], + [ '10', { rowspan: 2, contents: 'aa' }, 'ab', '13' ], + [ '20', 'bb', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 0, 0, 0, 0 ], + [ 0, 1, 1, 0 ], + [ 0, 1, 0 ], + [ 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( 'handles pasting table that has many cells with various rowspan', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 3 ] ) + ); + + pasteTable( [ + [ 'aa', { rowspan: 3, contents: 'ab' }, { rowspan: 2, contents: 'ac' }, 'ad' ], + [ { rowspan: 2, contents: 'ba' }, 'bd' ], + [ 'cc', 'cd' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', { rowspan: 3, contents: 'ab' }, { rowspan: 2, contents: 'ac' }, 'ad' ], + [ { rowspan: 2, contents: 'ba' }, 'bd' ], + [ 'cc', 'cd' ], + [ '30', '31', '32', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 1, 1, 1, 1 ], + [ 1, 1 ], + [ 1, 1 ], + [ 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( 'handles pasting multi-spanned table', () => { + setModelData( model, modelTable( [ + [ '00', '01', '02', '03', '04', '05' ], + [ '10', '11', '12', '13', '14', '15' ], + [ '20', '21', '22', '23', '24', '25' ], + [ '30', '31', '32', '33', '34', '35' ], + [ '40', '41', '42', '43', '44', '45' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 3, 4 ] ) + ); + + // +----+----+----+----+----+ + // | aa | ac | ad | ae | + // +----+----+----+----+ + + // | ba | bb | | + // +----+ +----+ + // | ca | | ce | + // + +----+----+----+----+ + // | | db | dc | dd | + // +----+----+----+----+----+ + pasteTable( [ + [ { contents: 'aa', colspan: 2 }, 'ac', 'ad', { contents: 'ae', rowspan: 2 } ], + [ 'ba', { contents: 'bb', colspan: 3, rowspan: 2 } ], + [ { contents: 'ca', rowspan: 2 }, 'ce' ], + [ 'db', 'dc', { contents: 'dd', colspan: 2 } ] + ] ); + + // +----+----+----+----+----+----+ + // | aa | ac | ad | ae | 05 | + // +----+----+----+----+ +----+ + // | ba | bb | | 15 | + // +----+ +----+----+ + // | ca | | ce | 25 | + // + +----+----+----+----+----+ + // | | db | dc | dd | 35 | + // +----+----+----+----+----+----+ + // | 40 | 41 | 42 | 43 | 44 | 45 | + // +----+----+----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ { contents: 'aa', colspan: 2 }, 'ac', 'ad', { contents: 'ae', rowspan: 2 }, '05' ], + [ 'ba', { contents: 'bb', colspan: 3, rowspan: 2 }, '15' ], + [ { contents: 'ca', rowspan: 2 }, 'ce', '25' ], + [ 'db', 'dc', { contents: 'dd', colspan: 2 }, '35' ], + [ '40', '41', '42', '43', '44', '45' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 1, 1, 1, 1, 0 ], + [ 1, 1, 0 ], + [ 1, 1, 0 ], + [ 1, 1, 1, 0 ], + [ 0, 0, 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + } ); + + describe( 'content table has spans', () => { + it( 'handles pasting simple table over a table with colspans (no colspan exceeds selection)', () => { + setModelData( model, modelTable( [ + [ '00[]', '01', '02', '03' ], + [ { colspan: 3, contents: '10' }, '13' ], + [ { colspan: 2, contents: '20' }, '22', '23' ], + [ '30', '31', { colspan: 2, contents: '31' } ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 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', { colspan: 2, contents: '31' } ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 1, 1, 1, 0 ], + [ 1, 1, 1, 0 ], + [ 1, 1, 1, 0 ], + [ 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( 'handles pasting simple table over a table with rowspans (no rowspan exceeds selection)', () => { + setModelData( model, modelTable( [ + [ '00', { rowspan: 3, contents: '01' }, { rowspan: 2, contents: '02' }, '03' ], + [ { rowspan: 2, contents: '10' }, '13' ], + [ '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 0 ] ) + ); + + 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 simple table over table with multi-spans (no span exceeds selection)', () => { + // +----+----+----+----+----+----+ + // | 00 | 02 | 03 | 05 | + // + + + +----+ + // | | | | 15 | + // +----+----+----+ +----+ + // | 20 | 21 | | 25 | + // + +----+----+----+----+----+ + // | | 31 | 32 | 34 | 35 | + // +----+----+----+----+----+----+ + // | 40 | 41 | 42 | 43 | 44 | 45 | + // +----+----+----+----+----+----+ + setModelData( model, modelTable( [ + [ + { contents: '00', colspan: 2, rowspan: 2 }, + { contents: '02', rowspan: 2 }, + { contents: '03', colspan: 2, rowspan: 3 }, + '05' + ], + [ '15' ], + [ { contents: '20', rowspan: 2 }, { contents: '21', colspan: 2 }, '25' ], + [ '31', { contents: '32', colspan: 2 }, '34', '35' ], + [ '40', '41', '42', '43', '44', '45' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 3, 2 ] ) // Cell 34. + ); + + pasteTable( [ + [ 'aa', 'ab', 'ac', 'ad', 'ae' ], + [ 'ba', 'bb', 'bc', 'bd', 'be' ], + [ 'ca', 'cb', 'cc', 'cd', 'ce' ], + [ 'da', 'db', 'dc', 'dd', 'de' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', 'ab', 'ac', 'ad', 'ae', '05' ], + [ 'ba', 'bb', 'bc', 'bd', 'be', '15' ], + [ 'ca', 'cb', 'cc', 'cd', 'ce', '25' ], + [ 'da', 'db', 'dc', 'dd', 'de', '35' ], + [ '40', '41', '42', '43', '44', '45' ] + ] ) ); + + assertSelectedCells( model, [ + [ 1, 1, 1, 1, 1, 0 ], + [ 1, 1, 1, 1, 1, 0 ], + [ 1, 1, 1, 1, 1, 0 ], + [ 1, 1, 1, 1, 1, 0 ], + [ 0, 0, 0, 0, 0, 0 ] + ] ); + } ); + + it( 'handles pasting simple table over a table with rowspan (rowspan before selection)', () => { + // +----+----+----+----+----+ + // | 00 | 01 | 02 | 03 | 04 | + // +----+----+----+----+----+ + // | 10 | 11 | 12 | 13 | 14 | + // +----+ +----+----+----+ + // | 20 | | 22 | 23 | 24 | + // +----+ +----+----+----+ + // | 30 | | 32 | 33 | 34 | + // +----+----+----+----+----+ + // | 40 | 41 | 42 | 43 | 44 | + // +----+----+----+----+----+ + setModelData( model, modelTable( [ + [ '00', '01', '02', '03', '04' ], + [ '10', { contents: '11', rowspan: 3 }, '12', '13', '14' ], + [ '20', '22', '23', '24' ], + [ '30', '32', '33', '34' ], + [ '40', '41', '42', '43', '44' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 2 ] ), + modelRoot.getNodeByPath( [ 0, 3, 2 ] ) // Cell 33. + ); + + pasteTable( [ + [ 'aa', 'ab' ], + [ 'ba', 'bb' ], + [ 'ca', 'cb' ] + ] ); + + // +----+----+----+----+----+ + // | 00 | 01 | 02 | 03 | 04 | + // +----+----+----+----+----+ + // | 10 | 11 | aa | ab | 14 | + // +----+ +----+----+----+ + // | 20 | | ba | bb | 24 | + // +----+ +----+----+----+ + // | 30 | | ca | cb | 34 | + // +----+----+----+----+----+ + // | 40 | 41 | 42 | 43 | 44 | + // +----+----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03', '04' ], + [ '10', { contents: '11', rowspan: 3 }, 'aa', 'ab', '14' ], + [ '20', 'ba', 'bb', '24' ], + [ '30', 'ca', 'cb', '34' ], + [ '40', '41', '42', '43', '44' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 0, 0, 0, 0, 0 ], + [ 0, 0, 1, 1, 0 ], + [ 0, 1, 1, 0 ], + [ 0, 1, 1, 0 ], + [ 0, 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( 'handles pasting simple table over a table with rowspans (rowspan before selection)', () => { + // +----+----+----+----+----+----+ + // | 00 | 01 | 02 | 03 | 04 | 05 | + // +----+----+----+----+----+----+ + // | 10 | 12 | 13 | 14 | 15 | + // +----+----+----+----+----+----+ + // | 20 | 23 | 24 | 25 | + // +----+----+----+----+----+----+ + // | 30 | 31 | 32 | 33 | 34 | 35 | + // +----+----+----+----+----+----+ + setModelData( model, modelTable( [ + [ '00', '01', '02', '03', '04', '05' ], + [ { contents: '10', colspan: 2 }, '12', '13', '14', '15' ], + [ { contents: '20', colspan: 3 }, '23', '24', '25' ], + [ '30', '31', '32', '33', '34', '35' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 2 ] ), // Cell 13. + modelRoot.getNodeByPath( [ 0, 2, 2 ] ) // Cell 24. + ); + + pasteTable( [ + [ 'aa', 'ab' ], + [ 'ba', 'bb' ] + ] ); + + // +----+----+----+----+----+----+ + // | 00 | 01 | 02 | 03 | 04 | 05 | + // +----+----+----+----+----+----+ + // | 10 | 12 | aa | ab | 15 | + // +----+----+----+----+----+----+ + // | 20 | ba | bb | 25 | + // +----+----+----+----+----+----+ + // | 30 | 31 | 32 | 33 | 34 | 35 | + // +----+----+----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03', '04', '05' ], + [ { contents: '10', colspan: 2 }, '12', 'aa', 'ab', '15' ], + [ { contents: '20', colspan: 3 }, 'ba', 'bb', '25' ], + [ '30', '31', '32', '33', '34', '35' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 0, 0, 0, 0, 0, 0 ], + [ 0, 0, 1, 1, 0 ], + [ 0, 1, 1, 0 ], + [ 0, 0, 0, 0, 0, 0 ] + ] ); + /* 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)', () => { + // +----+----+----+----+ + // | 00 | 01 | 02 | 03 | + // +----+----+----+----+ + // | 10 | 11 | 13 | + // + + +----+ + // | | | 23 | + // +----+----+----+----+ + // | 30 | 31 | 32 | 33 | + // +----+----+----+----+ + setModelData( model, modelTable( [ + [ '00', '01', '02', '03' ], + [ { contents: '10', rowspan: 2 }, { contents: '11', colspan: 2, rowspan: 2 }, '13' ], + [ '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 2 ] ), + modelRoot.getNodeByPath( [ 0, 1, 0 ] ) + ); + + 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 ] + ] ); + } ); + } ); + + describe( 'content and paste tables have spans', () => { + it( 'handles pasting colspanned table over table with colspans (no colspan exceeds selection)', () => { + setModelData( model, modelTable( [ + [ '00[]', '01', '02', '03' ], + [ { colspan: 3, contents: '10' }, '13' ], + [ { colspan: 2, contents: '20' }, '22', '23' ], + [ '30', '31', { colspan: 2, contents: '31' } ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 1 ] ) + ); + + pasteTable( [ + [ 'aa', { colspan: 2, contents: 'ab' } ], + [ { colspan: 3, contents: 'ba' } ], + [ 'ca', 'cb', 'cc' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', { colspan: 2, contents: 'ab' }, '03' ], + [ { colspan: 3, contents: 'ba' }, '13' ], + [ 'ca', 'cb', 'cc', '23' ], + [ '30', '31', { colspan: 2, contents: '31' } ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 1, 1, 0 ], + [ 1, 0 ], + [ 1, 1, 1, 0 ], + [ 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( 'handles pasting rowspanned table over table with rowspans (no rowspan exceeds selection)', () => { + setModelData( model, modelTable( [ + [ { rowspan: 3, contents: '00' }, { rowspan: 2, contents: '01' }, '02', '03' ], + [ { rowspan: 2, contents: '12' }, '13' ], + [ '21', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 1 ] ) + ); + + pasteTable( [ + [ 'aa', { rowspan: 3, contents: 'ab' }, { rowspan: 2, contents: 'ac' }, 'ad' ], + [ { rowspan: 2, contents: 'ba' }, 'bd' ], + [ 'cc', 'cd' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', { rowspan: 3, contents: 'ab' }, { rowspan: 2, contents: 'ac' }, 'ad' ], + [ { rowspan: 2, contents: 'ba' }, 'bd' ], + [ 'cc', 'cd' ], + [ '30', '31', '32', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 1, 1, 1, 1 ], + [ 1, 1 ], + [ 1, 1 ], + [ 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( 'handles pasting multi-spanned table over table with multi-spans (no span exceeds selection)', () => { + // +----+----+----+----+----+----+ + // | 00 | 02 | 03 | 05 | + // + + + +----+ + // | | | | 15 | + // +----+----+----+ +----+ + // | 20 | 21 | | 25 | + // + +----+----+----+----+----+ + // | | 31 | 32 | 34 | 35 | + // +----+----+----+----+----+----+ + // | 40 | 41 | 42 | 43 | 44 | 45 | + // +----+----+----+----+----+----+ + setModelData( model, modelTable( [ + [ + { contents: '00', colspan: 2, rowspan: 2 }, + { contents: '02', rowspan: 2 }, + { contents: '03', colspan: 2, rowspan: 3 }, + '05' + ], + [ '15' ], + [ { contents: '20', rowspan: 2 }, { contents: '21', colspan: 2 }, '25' ], + [ '31', { contents: '32', colspan: 2 }, '34', '35' ], + [ '40', '41', '42', '43', '44', '45' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 3, 2 ] ) + ); + + // +----+----+----+----+----+ + // | aa | ac | ad | ae | + // +----+----+----+----+ + + // | ba | bb | | + // +----+ +----+ + // | ca | | ce | + // + +----+----+----+----+ + // | | db | dc | dd | + // +----+----+----+----+----+ + pasteTable( [ + [ { contents: 'aa', colspan: 2 }, 'ac', 'ad', { contents: 'ae', rowspan: 2 } ], + [ 'ba', { contents: 'bb', colspan: 3, rowspan: 2 } ], + [ { contents: 'ca', rowspan: 2 }, 'ce' ], + [ 'db', 'dc', { contents: 'dd', colspan: 2 } ] + ] ); + + // +----+----+----+----+----+----+ + // | aa | ac | ad | ae | 05 | + // +----+----+----+----+ +----+ + // | ba | bb | | 15 | + // +----+ +----+----+ + // | ca | | ce | 25 | + // + +----+----+----+----+----+ + // | | db | dc | dd | 35 | + // +----+----+----+----+----+----+ + // | 40 | 41 | 42 | 43 | 44 | 45 | + // +----+----+----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ { contents: 'aa', colspan: 2 }, 'ac', 'ad', { contents: 'ae', rowspan: 2 }, '05' ], + [ 'ba', { contents: 'bb', colspan: 3, rowspan: 2 }, '15' ], + [ { contents: 'ca', rowspan: 2 }, 'ce', '25' ], + [ 'db', 'dc', { contents: 'dd', colspan: 2 }, '35' ], + [ '40', '41', '42', '43', '44', '45' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 1, 1, 1, 1, 0 ], + [ 1, 1, 0 ], + [ 1, 1, 0 ], + [ 1, 1, 1, 0 ], + [ 0, 0, 0, 0, 0, 0 ] + ] ); + /* 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)', () => { + // +----+----+----+----+ + // | 00 | 01 | 02 | 03 | + // +----+----+----+----+ + // | 10 | 11 | 13 | + // + + +----+ + // | | | 23 | + // +----+----+----+----+ + // | 30 | 31 | 32 | 33 | + // +----+----+----+----+ + setModelData( model, modelTable( [ + [ '00', '01', '02', '03' ], + [ { contents: '10', rowspan: 2 }, { contents: '11', colspan: 2, rowspan: 2 }, '13' ], + [ '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 2 ] ), + modelRoot.getNodeByPath( [ 0, 1, 0 ] ) + ); + + // +----+----+----+ + // | aa | ab | ac | + // +----+----+----+ + // | ba | bc | + // + +----+ + // | | cc | + // +----+----+----+ + pasteTable( [ + [ 'aa', 'ab', 'ac' ], + [ { contents: 'ba', colspan: 2, rowspan: 2 }, 'bc' ], + [ 'cc' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03' ], + [ '10', { colspan: 2, contents: 'aa' }, '13' ], + [ '20', 'ba', 'bb', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 0, 0, 0, 0 ], + [ 0, 1, 0 ], + [ 0, 1, 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', () => { + 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( [ + [ 'aa', 'ab', 'ac' ], + [ 'ba', 'bb', 'bc' ], + [ 'ca', 'cb', 'cc' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03' ], + [ '10', 'aa', 'ab', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + assertSelectedCells( model, [ + [ 0, 0, 0, 0 ], + [ 0, 1, 1, 0 ], + [ 0, 0, 0, 0 ], + [ 0, 0, 0, 0 ] + ] ); + } ); + + it( 'handles paste to a simple column fragment - in the middle of a table', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 1 ] ), + modelRoot.getNodeByPath( [ 0, 2, 1 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab', 'ac' ], + [ 'ba', 'bb', 'bc' ], + [ 'ca', 'cb', 'cc' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03' ], + [ '10', 'aa', '12', '13' ], + [ '20', 'ba', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + assertSelectedCells( model, [ + [ 0, 0, 0, 0 ], + [ 0, 1, 0, 0 ], + [ 0, 1, 0, 0 ], + [ 0, 0, 0, 0 ] + ] ); + } ); + + it( 'handles simple table paste to a simple table fragment - whole table selected', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 3, 3 ] ) + ); + + pasteTable( [ + [ 'aa', 'ab', 'ac', 'ad', 'ae' ], + [ 'ba', 'bb', 'bc', 'bd', 'be' ], + [ 'ca', 'cb', 'cc', 'cd', 'ce' ], + [ 'da', 'db', 'dc', 'dd', 'de' ], + [ 'ea', 'eb', 'ec', 'ed', 'ee' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', 'ab', 'ac', 'ad' ], + [ 'ba', 'bb', 'bc', 'bd' ], + [ 'ca', 'cb', 'cc', 'cd' ], + [ 'da', 'db', 'dc', 'dd' ] + ] ) ); + + assertSelectedCells( model, [ + [ 1, 1, 1, 1 ], + [ 1, 1, 1, 1 ], + [ 1, 1, 1, 1 ], + [ 1, 1, 1, 1 ] + ] ); + } ); + } ); + + describe( 'pasted table has spans', () => { + it( 'handles pasting table that has cell with colspan', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 1 ] ), + modelRoot.getNodeByPath( [ 0, 2, 2 ] ) + ); + + pasteTable( [ + [ { colspan: 3, contents: 'aa' } ], + [ 'ba', 'bb', 'bc' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03' ], + [ '10', { colspan: 2, contents: 'aa' }, '13' ], + [ '20', 'ba', 'bb', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 0, 0, 0, 0 ], + [ 0, 1, 0 ], + [ 0, 1, 1, 0 ], + [ 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( 'handles pasting table that has many cells with various colspan', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 3, 2 ] ) + ); + + pasteTable( [ + [ 'aa', { colspan: 3, contents: 'ab' } ], + [ { colspan: 4, contents: 'ba' } ], + [ 'ca', 'cb', 'cc', 'cd' ], + [ { colspan: 2, contents: 'da' }, 'dc', 'dd' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', { colspan: 2, contents: 'ab' }, '03' ], + [ { colspan: 3, contents: 'ba' }, '13' ], + [ 'ca', 'cb', 'cc', '23' ], + [ { colspan: 2, contents: 'da' }, 'dc', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 1, 1, 0 ], + [ 1, 0 ], + [ 1, 1, 1, 0 ], + [ 1, 1, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( 'handles pasting table that has cell with rowspan', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 1 ] ), + modelRoot.getNodeByPath( [ 0, 2, 2 ] ) + ); + + pasteTable( [ + [ { rowspan: 3, contents: 'aa' }, 'ab' ], + [ 'bb' ], + [ 'cb' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03' ], + [ '10', { rowspan: 2, contents: 'aa' }, 'ab', '13' ], + [ '20', 'bb', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 0, 0, 0, 0 ], + [ 0, 1, 1, 0 ], + [ 0, 1, 0 ], + [ 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( 'handles pasting table that has many cells with various rowspan', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 3 ] ) + ); + + pasteTable( [ + [ 'aa', { rowspan: 3, contents: 'ab' }, { rowspan: 2, contents: 'ac' }, 'ad', 'ae' ], + [ { rowspan: 3, contents: 'ba' }, 'bd', 'be' ], + [ 'cc', 'cd', 'ce' ], + [ 'da', 'db', 'dc', 'dd' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', { rowspan: 3, contents: 'ab' }, { rowspan: 2, contents: 'ac' }, 'ad' ], + [ { rowspan: 2, contents: 'ba' }, 'bd' ], + [ 'cc', 'cd' ], + [ '30', '31', '32', '33' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 1, 1, 1, 1 ], + [ 1, 1 ], + [ 1, 1 ], + [ 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + + it( 'handles pasting multi-spanned table', () => { + setModelData( model, modelTable( [ + [ '00', '01', '02', '03', '04', '05' ], + [ '10', '11', '12', '13', '14', '15' ], + [ '20', '21', '22', '23', '24', '25' ], + [ '30', '31', '32', '33', '34', '35' ], + [ '40', '41', '42', '43', '44', '45' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 1 ] ), + modelRoot.getNodeByPath( [ 0, 3, 3 ] ) + ); + + // +----+----+----+----+----+ + // | aa | ac | ad | ae | + // +----+----+----+----+ + + // | ba | bb | | + // +----+ +----+ + // | ca | | ce | + // + +----+----+----+----+ + // | | db | dc | dd | + // +----+----+----+----+----+ + pasteTable( [ + [ { contents: 'aa', colspan: 2 }, 'ac', 'ad', { contents: 'ae', rowspan: 2 } ], + [ 'ba', { contents: 'bb', colspan: 3, rowspan: 2 } ], + [ { contents: 'ca', rowspan: 2 }, 'ce' ], + [ 'db', 'dc', { contents: 'dd', colspan: 2 } ] + ] ); + + // +----+----+----+----+----+----+ + // | 00 | 01 | 02 | 03 | 04 | 05 | + // +----+----+----+----+----+----+ + // | 10 | aa | ac | 14 | 15 | + // +----+----+----+----+----+----+ + // | 20 | ba | bb | 24 | 25 | + // +----+----+ +----+----+ + // | 30 | ca | | 34 | 35 | + // +----+----+----+----+----+----+ + // | 40 | 41 | 42 | 43 | 44 | 45 | + // +----+----+----+----+----+----+ + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '00', '01', '02', '03', '04', '05' ], + [ '10', { contents: 'aa', colspan: 2 }, 'ac', '14', '15' ], + [ '20', 'ba', { contents: 'bb', colspan: 2, rowspan: 2 }, '24', '25' ], + [ '30', 'ca', '34', '35' ], + [ '40', '41', '42', '43', '44', '45' ] + ] ) ); + + /* eslint-disable no-multi-spaces */ + assertSelectedCells( model, [ + [ 0, 0, 0, 0, 0, 0 ], + [ 0, 1, 1, 0, 0 ], + [ 0, 1, 1, 0, 0 ], + [ 0, 1, 0, 0 ], + [ 0, 0, 0, 0, 0, 0 ] + ] ); + /* eslint-enable no-multi-spaces */ + } ); + } ); + } ); + + describe( 'pasted table is smaller than the selected area', () => { + it( 'blocks this case', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 3, 3 ] ) + ); + + // 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( [ + [ '00', '01', '02', '03' ], + [ '10', '11', '12', '13' ], + [ '20', '21', '22', '23' ], + [ '30', '31', '32', '33' ] + ] ) ); + + assertSelectedCells( model, [ + [ 1, 1, 1, 1 ], + [ 1, 1, 1, 1 ], + [ 1, 1, 1, 1 ], + [ 1, 1, 1, 1 ] + ] ); + } ); + } ); + } ); + + describe( 'Clipboard integration - paste (content scenarios)', () => { + it( 'handles multiple paragraphs in table cell', async () => { + await createEditor(); + + setModelData( model, modelTable( [ + [ '00', '01', '02' ], + [ '01', '11', '12' ], + [ '02', '21', '22' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + pasteTable( [ + [ '

a

a

a

', 'ab' ], + [ 'ba', 'bb' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aaa', 'ab', '02' ], + [ 'ba', 'bb', '12' ], + [ '02', '21', '22' ] + ] ) ); + } ); + + it( 'handles image in table cell', async () => { + await createEditor( [ ImageEditing, ImageCaptionEditing ] ); + + setModelData( model, modelTable( [ + [ '00', '01', '02' ], + [ '01', '11', '12' ], + [ '02', '21', '22' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + pasteTable( [ + [ '', 'ab' ], + [ 'ba', 'bb' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '', 'ab', '02' ], + [ 'ba', 'bb', '12' ], + [ '02', '21', '22' ] + ] ) ); + } ); + + it( 'handles mixed nested content in table cell', async () => { + await createEditor( [ ImageEditing, ImageCaptionEditing, BlockQuoteEditing, HorizontalLineEditing, ListEditing ] ); + + setModelData( model, modelTable( [ + [ '00', '01', '02' ], + [ '01', '11', '12' ], + [ '02', '21', '22' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + const img = ''; + const list = '
  • foo
  • bar
'; + const blockquote = `

baz

${ list }
`; + + pasteTable( [ + [ `${ img }${ list }${ blockquote }`, 'ab' ], + [ 'ba', 'bb' ] + ] ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ + '' + + 'foo' + + 'bar' + + '
' + + 'baz' + + 'foo' + + 'bar' + + '
', + 'ab', + '02' ], + [ 'ba', 'bb', '12' ], + [ '02', '21', '22' ] + ] ) ); + } ); + + it( 'handles table cell properties', async () => { + await createEditor( [ TableCellPropertiesEditing ] ); + + setModelData( model, modelTable( [ + [ '00', '01', '02' ], + [ '01', '11', '12' ], + [ '02', '21', '22' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + pasteTable( [ + [ { contents: 'aa', style: 'border:1px solid #f00;background:#ba7;width:1337px' }, 'ab' ], + [ 'ba', 'bb' ] + ] ); + + const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] ); + + expect( tableCell.getAttribute( 'borderColor' ) ).to.deep.equal( { + top: '#f00', + right: '#f00', + bottom: '#f00', + left: '#f00' + } ); + expect( tableCell.getAttribute( 'borderStyle' ) ).to.deep.equal( { + top: 'solid', + right: 'solid', + bottom: 'solid', + left: 'solid' + } ); + expect( tableCell.getAttribute( 'borderWidth' ) ).to.deep.equal( { + top: '1px', + right: '1px', + bottom: '1px', + left: '1px' + } ); + expect( tableCell.getAttribute( 'backgroundColor' ) ).to.equal( '#ba7' ); + expect( tableCell.getAttribute( 'width' ) ).to.equal( '1337px' ); + } ); + + it( 'discards table properties', async () => { + await createEditor( [ TableCellPropertiesEditing ] ); + + setModelData( model, modelTable( [ + [ '00', '01', '02' ], + [ '01', '11', '12' ], + [ '02', '21', '22' ] + ] ) ); + + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + const tableStyle = 'border:1px solid #f00;background:#ba7;width:1337px'; + const pastedTable = `
aaab
babb
`; + const data = { + dataTransfer: createDataTransfer(), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + data.dataTransfer.setData( 'text/html', pastedTable ); + viewDocument.fire( 'paste', data ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ 'aa', 'ab', '02' ], + [ 'ba', 'bb', '12' ], + [ '02', '21', '22' ] + ] ) ); + } ); + } ); + + async function createEditor( extraPlugins = [] ) { + editor = await ClassicTestEditor.create( element, { + plugins: [ TableEditing, TableClipboard, Paragraph, Clipboard, ...extraPlugins ] + } ); + + model = editor.model; + modelRoot = model.document.getRoot(); + viewDocument = editor.editing.view.document; + tableSelection = editor.plugins.get( 'TableSelection' ); + } + + function pasteTable( tableData ) { + const data = { + dataTransfer: createDataTransfer(), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + data.dataTransfer.setData( 'text/html', viewTable( tableData ) ); + viewDocument.fire( 'paste', data ); + + return data; + } + + function createDataTransfer() { + const store = new Map(); + + return { + setData( type, data ) { + store.set( type, data ); + }, + + getData( type ) { + return store.get( type ); + } + }; + } +} ); diff --git a/packages/ckeditor5-table/tests/tableclipboard.js b/packages/ckeditor5-table/tests/tableclipboard.js index c0af3cb6b2d..9b736a15b81 100644 --- a/packages/ckeditor5-table/tests/tableclipboard.js +++ b/packages/ckeditor5-table/tests/tableclipboard.js @@ -3,415 +3,34 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; -import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; + +import TableSelection from '../src/tableselection'; +import TableUtils from '../src/tableutils'; -import TableEditing from '../src/tableediting'; -import { modelTable, viewTable } from './_utils/utils'; -import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; import TableClipboard from '../src/tableclipboard'; -import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; describe( 'table clipboard', () => { - let editor, model, modelRoot, tableSelection, viewDocument; + let editor; beforeEach( async () => { editor = await VirtualTestEditor.create( { - plugins: [ TableEditing, TableClipboard, Paragraph, Clipboard ] + plugins: [ TableClipboard, Paragraph ] } ); - - model = editor.model; - modelRoot = model.document.getRoot(); - viewDocument = editor.editing.view.document; - tableSelection = editor.plugins.get( 'TableSelection' ); - - setModelData( model, modelTable( [ - [ '00[]', '01', '02' ], - [ '10', '11', '12' ], - [ '20', '21', '22' ] - ] ) ); } ); - afterEach( async () => { - await editor.destroy(); + afterEach( () => { + return editor.destroy(); } ); - describe( 'Clipboard integration', () => { - describe( 'copy', () => { - it( 'should do nothing for normal selection in table', () => { - const dataTransferMock = createDataTransfer(); - const spy = sinon.spy(); - - viewDocument.on( 'clipboardOutput', spy ); - - viewDocument.fire( 'copy', { - dataTransfer: dataTransferMock, - preventDefault: sinon.spy() - } ); - - sinon.assert.calledOnce( spy ); - } ); - - it( 'should copy selected table cells as a standalone table', () => { - const preventDefaultSpy = sinon.spy(); - - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 0, 1 ] ), - modelRoot.getNodeByPath( [ 0, 1, 2 ] ) - ); - - const data = { - dataTransfer: createDataTransfer(), - preventDefault: preventDefaultSpy - }; - viewDocument.fire( 'copy', data ); - - sinon.assert.calledOnce( preventDefaultSpy ); - expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( viewTable( [ - [ '01', '02' ], - [ '11', '12' ] - ] ) ); - } ); - - it( 'should trim selected table to a selection rectangle (inner cell with colspan, no colspan after trim)', () => { - setModelData( model, modelTable( [ - [ '00[]', '01', '02' ], - [ '10', { contents: '11', colspan: 2 } ], - [ '20', '21', '22' ] - ] ) ); - - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 0, 0 ] ), - modelRoot.getNodeByPath( [ 0, 2, 1 ] ) - ); - - assertClipboardContentOnMethod( 'copy', viewTable( [ - [ '00', '01' ], - [ '10', '11' ], - [ '20', '21' ] - ] ) ); - } ); - - it( 'should trim selected table to a selection rectangle (inner cell with colspan, has colspan after trim)', () => { - setModelData( model, modelTable( [ - [ '00[]', '01', '02' ], - [ { contents: '10', colspan: 3 } ], - [ '20', '21', '22' ] - ] ) ); - - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 0, 0 ] ), - modelRoot.getNodeByPath( [ 0, 2, 1 ] ) - ); - - assertClipboardContentOnMethod( 'copy', viewTable( [ - [ '00', '01' ], - [ { contents: '10', colspan: 2 } ], - [ '20', '21' ] - ] ) ); - } ); - - it( 'should trim selected table to a selection rectangle (inner cell with rowspan, no colspan after trim)', () => { - setModelData( model, modelTable( [ - [ '00[]', '01', '02' ], - [ '10', { contents: '11', rowspan: 2 }, '12' ], - [ '20', '21', '22' ] - ] ) ); - - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 0, 0 ] ), - modelRoot.getNodeByPath( [ 0, 1, 2 ] ) - ); - - assertClipboardContentOnMethod( 'copy', viewTable( [ - [ '00', '01', '02' ], - [ '10', '11', '12' ] - ] ) ); - } ); - - it( 'should trim selected table to a selection rectangle (inner cell with rowspan, has rowspan after trim)', () => { - setModelData( model, modelTable( [ - [ '00[]', { contents: '01', rowspan: 3 }, '02' ], - [ '10', '12' ], - [ '20', '22' ] - ] ) ); - - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 0, 0 ] ), - modelRoot.getNodeByPath( [ 0, 1, 1 ] ) - ); - - assertClipboardContentOnMethod( 'copy', viewTable( [ - [ '00', { contents: '01', rowspan: 2 }, '02' ], - [ '10', '12' ] - ] ) ); - } ); - - it( 'should prepend spanned columns with empty cells (outside cell with colspan)', () => { - setModelData( model, modelTable( [ - [ '00[]', '01', '02' ], - [ { contents: '10', colspan: 2 }, '12' ], - [ '20', '21', '22' ] - ] ) ); - - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 0, 1 ] ), - modelRoot.getNodeByPath( [ 0, 2, 2 ] ) - ); - - assertClipboardContentOnMethod( 'copy', viewTable( [ - [ '01', '02' ], - [ ' ', '12' ], - [ '21', '22' ] - ] ) ); - } ); - - it( 'should prepend spanned columns with empty cells (outside cell with rowspan)', () => { - setModelData( model, modelTable( [ - [ '00[]', { contents: '01', rowspan: 2 }, '02' ], - [ '10', '12' ], - [ '20', '21', '22' ] - ] ) ); - - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 1, 0 ] ), - modelRoot.getNodeByPath( [ 0, 2, 2 ] ) - ); - - assertClipboardContentOnMethod( 'copy', viewTable( [ - [ '10', ' ', '12' ], - [ '20', '21', '22' ] - ] ) ); - } ); - - it( 'should fix selected table to a selection rectangle (hardcore case)', () => { - // This test check how previous simple rules run together (mixed prepending and trimming). - // In the example below a selection is set from cell "32" to "88" - // - // Input table: Copied table: - // - // +----+----+----+----+----+----+----+----+----+ - // | 00 | 01 | 02 | 03 | 04 | 06 | 07 | 08 | - // +----+----+ +----+ +----+----+----+ - // | 10 | 11 | | 13 | | 16 | 17 | 18 | - // +----+----+ +----+ +----+----+----+ +----+----+----+---------+----+----+ - // | 20 | 21 | | 23 | | 26 | | 21 | | 23 | | | 26 | | - // +----+----+ +----+ +----+----+----+ +----+----+----+----+----+----+----+ - // | 30 | 31 | | 33 | | 36 | 37 | | 31 | | 33 | | | 36 | 37 | - // +----+----+----+----+ +----+----+----+ +----+----+----+----+----+----+----+ - // | 40 | | 46 | 47 | 48 | | | | | | | 46 | 47 | - // +----+----+----+----+ +----+----+----+ ==> +----+----+----+----+----+----+----+ - // | 50 | 51 | 52 | 53 | | 56 | 57 | 58 | | 51 | 52 | 53 | | | 56 | 57 | - // +----+----+----+----+----+----+ +----+----+ +----+----+----+----+----+----+----+ - // | 60 | 61 | 64 | 65 | | 67 | 68 | | 61 | | | 64 | 65 | | 67 | - // +----+----+----+----+----+----+ +----+----+ +----+----+----+----+----+----+----+ - // | 70 | 71 | 72 | 73 | 74 | 75 | | 77 | 78 | | 71 | 72 | 73 | 74 | 75 | | 77 | - // +----+ +----+----+----+----+ +----+----+ +----+----+----+----+----+----+----+ - // | 80 | | 82 | 83 | 84 | 85 | | 87 | 88 | - // +----+----+----+----+----+----+----+----+----+ - // - setModelData( model, modelTable( [ - [ '00', '01', { contents: '02', rowspan: 4 }, '03', { contents: '04', colspan: 2, rowspan: 7 }, '07', '07', '08' ], - [ '10', '11', '13', '17', '17', '18' ], - [ '20', '21', '23', { contents: '27', colspan: 3 } ], - [ '30', '31', '33', '37', { contents: '37', colspan: 2 } ], - [ { contents: '40', colspan: 4 }, '47', '47', '48' ], - [ '50', '51', '52', '53', { contents: '57', rowspan: 4 }, '57', '58' ], - [ '60', { contents: '61', colspan: 3 }, '67', '68' ], - [ '70', { contents: '71', rowspan: 2 }, '72', '73', '74', '75', '77', '78' ], - [ '80', '82', '83', '84', '85', '87', '88' ] - ] ) ); - - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 2, 1 ] ), - modelRoot.getNodeByPath( [ 0, 7, 6 ] ) - ); - - assertClipboardContentOnMethod( 'copy', viewTable( [ - [ '21', ' ', '23', ' ', ' ', { contents: '27', colspan: 2 } ], - [ '31', ' ', '33', ' ', ' ', '37', '37' ], - [ ' ', ' ', ' ', ' ', ' ', '47', '47' ], - [ '51', '52', '53', ' ', ' ', { contents: '57', rowspan: 3 }, '57' ], - [ { contents: '61', colspan: 3 }, ' ', ' ', ' ', '67' ], - [ '71', '72', '73', '74', '75', '77' ] - ] ) ); - } ); - - it( 'should update table heading attributes (selection with headings)', () => { - setModelData( model, modelTable( [ - [ '00', '01', '02', '03', '04' ], - [ '10', '11', '12', '13', '14' ], - [ '20', '21', '22', '23', '24' ], - [ '30', '31', '32', '33', '34' ], - [ '40', '41', '42', '43', '44' ] - ], { headingRows: 3, headingColumns: 2 } ) ); - - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 1, 1 ] ), - modelRoot.getNodeByPath( [ 0, 3, 3 ] ) - ); - - assertClipboardContentOnMethod( 'copy', viewTable( [ - [ '11', '12', '13' ], - [ '21', '22', '23' ], - [ { contents: '31', isHeading: true }, '32', '33' ] // TODO: bug in viewTable - ], { headingRows: 2, headingColumns: 1 } ) ); - } ); - - it( 'should update table heading attributes (selection without headings)', () => { - setModelData( model, modelTable( [ - [ '00', '01', '02', '03', '04' ], - [ '10', '11', '12', '13', '14' ], - [ '20', '21', '22', '23', '24' ], - [ '30', '31', '32', '33', '34' ], - [ '40', '41', '42', '43', '44' ] - ], { headingRows: 3, headingColumns: 2 } ) ); - - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 3, 2 ] ), - modelRoot.getNodeByPath( [ 0, 4, 4 ] ) - ); - - assertClipboardContentOnMethod( 'copy', viewTable( [ - [ '32', '33', '34' ], - [ '42', '43', '44' ] - ] ) ); - } ); + describe( 'TableClipboard', () => { + it( 'should have pluginName', () => { + expect( TableClipboard.pluginName ).to.equal( 'TableClipboard' ); } ); - describe( 'cut', () => { - it( 'should not block clipboardOutput if no multi-cell selection', () => { - setModelData( model, modelTable( [ - [ '[00]', '01', '02' ], - [ '10', '11', '12' ], - [ '20', '21', '22' ] - ] ) ); - - const dataTransferMock = createDataTransfer(); - - viewDocument.fire( 'cut', { - dataTransfer: dataTransferMock, - preventDefault: sinon.spy() - } ); - - expect( dataTransferMock.getData( 'text/html' ) ).to.equal( '00' ); - } ); - - it( 'should be preventable', () => { - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 0, 0 ] ), - modelRoot.getNodeByPath( [ 0, 1, 1 ] ) - ); - - viewDocument.on( 'clipboardOutput', evt => evt.stop(), { priority: 'high' } ); - - viewDocument.fire( 'cut', { - dataTransfer: createDataTransfer(), - preventDefault: sinon.spy() - } ); - - assertEqualMarkup( getModelData( model ), modelTable( [ - [ { contents: '00', isSelected: true }, { contents: '01', isSelected: true }, '02' ], - [ { contents: '10', isSelected: true }, { contents: '11', isSelected: true }, '12' ], - [ '20', '21', '22' ] - ] ) ); - } ); - - it( 'is clears selected table cells', () => { - const spy = sinon.spy(); - - viewDocument.on( 'clipboardOutput', spy ); - - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 0, 0 ] ), - modelRoot.getNodeByPath( [ 0, 1, 1 ] ) - ); - - viewDocument.fire( 'cut', { - dataTransfer: createDataTransfer(), - preventDefault: sinon.spy() - } ); - - assertEqualMarkup( getModelData( model ), modelTable( [ - [ '', '', '02' ], - [ '', '[]', '12' ], - [ '20', '21', '22' ] - ] ) ); - } ); - - it( 'should copy selected table cells as a standalone table', () => { - const preventDefaultSpy = sinon.spy(); - - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 0, 1 ] ), - modelRoot.getNodeByPath( [ 0, 1, 2 ] ) - ); - - const data = { - dataTransfer: createDataTransfer(), - preventDefault: preventDefaultSpy - }; - viewDocument.fire( 'cut', data ); - - sinon.assert.calledOnce( preventDefaultSpy ); - expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( viewTable( [ - [ '01', '02' ], - [ '11', '12' ] - ] ) ); - } ); - - it( 'should be disabled in a readonly mode', () => { - const preventDefaultStub = sinon.stub(); - - editor.isReadOnly = true; - - tableSelection.setCellSelection( - modelRoot.getNodeByPath( [ 0, 0, 1 ] ), - modelRoot.getNodeByPath( [ 0, 1, 2 ] ) - ); - - const data = { - dataTransfer: createDataTransfer(), - preventDefault: preventDefaultStub - }; - viewDocument.fire( 'cut', data ); - - editor.isReadOnly = false; - - expect( data.dataTransfer.getData( 'text/html' ) ).to.be.undefined; - assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ - [ '00', '01', '02' ], - [ '10', '11', '12' ], - [ '20', '21', '22' ] - ] ) ); - - sinon.assert.calledOnce( preventDefaultStub ); - } ); + it( 'requires TableSelection and TableUtils ', () => { + expect( TableClipboard.requires ).to.deep.equal( [ TableSelection, TableUtils ] ); } ); } ); - - function assertClipboardContentOnMethod( method, expectedViewTable ) { - const data = { - dataTransfer: createDataTransfer(), - preventDefault: sinon.spy() - }; - viewDocument.fire( method, data ); - - expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( expectedViewTable ); - } - - function createDataTransfer() { - const store = new Map(); - - return { - setData( type, data ) { - store.set( type, data ); - }, - - getData( type ) { - return store.get( type ); - } - }; - } } );