Skip to content

Commit

Permalink
Merge pull request #8286 from ckeditor/cf/3466
Browse files Browse the repository at this point in the history
Internal (table): Exposed API for integration with `TableClipboard`.
  • Loading branch information
jodator committed Oct 20, 2020
2 parents d19a5fa + fd95650 commit f60cbd5
Show file tree
Hide file tree
Showing 4 changed files with 384 additions and 149 deletions.
334 changes: 186 additions & 148 deletions packages/ckeditor5-table/src/tableclipboard.js
Expand Up @@ -56,6 +56,8 @@ 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' } );

this.decorate( '_replaceTableSlotCell' );
}

/**
Expand Down Expand Up @@ -164,7 +166,7 @@ export default class TableClipboard extends Plugin {
// Content table to which we insert a pasted table.
const selectedTable = selectedTableCells[ 0 ].findAncestor( 'table' );

const cellsToSelect = replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer );
const cellsToSelect = this._replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer );

if ( this.editor.plugins.get( 'TableSelection' ).isEnabled ) {
// Selection ranges must be sorted because the first and last selection ranges are considered
Expand All @@ -178,6 +180,189 @@ export default class TableClipboard extends Plugin {
}
} );
}

/**
* Replaces the part of selectedTable with pastedTable.
*
* @private
* @param {module:engine/model/element~Element} pastedTable
* @param {Object} pastedDimensions
* @param {Number} pastedDimensions.height
* @param {Number} pastedDimensions.width
* @param {module:engine/model/element~Element} selectedTable
* @param {Object} selection
* @param {Number} selection.firstColumn
* @param {Number} selection.firstRow
* @param {Number} selection.lastColumn
* @param {Number} selection.lastRow
* @param {module:engine/model/writer~Writer} writer
* @returns {Array.<module:engine/model/element~Element>}
*/
_replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer ) {
const { width: pastedWidth, height: pastedHeight } = pastedDimensions;

// Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
const pastedTableLocationMap = createLocationMap( pastedTable, pastedWidth, pastedHeight );

const selectedTableMap = [ ...new TableWalker( selectedTable, {
startRow: selection.firstRow,
endRow: selection.lastRow,
startColumn: selection.firstColumn,
endColumn: selection.lastColumn,
includeAllSlots: true
} ) ];

// Selection must be set to pasted cells (some might be removed or new created).
const cellsToSelect = [];

// Store next cell insert position.
let insertPosition;

// 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 tableSlot of selectedTableMap ) {
const { row, column } = tableSlot;

// Save the insert position for current row start.
if ( column === selection.firstColumn ) {
insertPosition = tableSlot.getPositionBefore();
}

// Map current table slot location to an pasted table slot location.
const pastedRow = row - selection.firstRow;
const pastedColumn = column - selection.firstColumn;
const pastedCell = pastedTableLocationMap[ pastedRow % pastedHeight ][ pastedColumn % pastedWidth ];

// 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 ? writer.cloneElement( pastedCell ) : null;

// Replace the cell from the current slot with new table cell.
const newTableCell = this._replaceTableSlotCell( tableSlot, cellToInsert, insertPosition, writer );

// The cell was only removed.
if ( !newTableCell ) {
continue;
}

// Trim the cell if it's row/col-spans would exceed selection area.
trimTableCellIfNeeded( newTableCell, row, column, selection.lastRow, selection.lastColumn, writer );

cellsToSelect.push( newTableCell );

insertPosition = writer.createPositionAfter( newTableCell );
}

// If there are any headings, all the cells that overlap from heading must be splitted.
const headingRows = parseInt( selectedTable.getAttribute( 'headingRows' ) || 0 );
const headingColumns = parseInt( selectedTable.getAttribute( 'headingColumns' ) || 0 );

const areHeadingRowsIntersectingSelection = selection.firstRow < headingRows && headingRows <= selection.lastRow;
const areHeadingColumnsIntersectingSelection = selection.firstColumn < headingColumns && headingColumns <= selection.lastColumn;

if ( areHeadingRowsIntersectingSelection ) {
const columnsLimit = { first: selection.firstColumn, last: selection.lastColumn };
const newCells = doHorizontalSplit( selectedTable, headingRows, columnsLimit, writer, selection.firstRow );

cellsToSelect.push( ...newCells );
}

if ( areHeadingColumnsIntersectingSelection ) {
const rowsLimit = { first: selection.firstRow, last: selection.lastRow };
const newCells = doVerticalSplit( selectedTable, headingColumns, rowsLimit, writer );

cellsToSelect.push( ...newCells );
}

return cellsToSelect;
}

/**
* Replaces a single table slot.
*
* @private
* @param {module:table/tablewalker~TableSlot} tableSlot
* @param {module:engine/model/element~Element} cellToInsert
* @param {module:engine/model/position~Position} insertPosition
* @param {module:engine/model/writer~Writer} writer
* @returns {module:engine/model/element~Element|null} Inserted table cell or null if slot should remain empty.
*/
_replaceTableSlotCell( tableSlot, cellToInsert, insertPosition, writer ) {
const { cell, isAnchor } = tableSlot;

// 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 ( isAnchor ) {
writer.remove( cell );
}

// There is no cell to insert (might be spanned by other cell in a pasted table) - advance to the next content table slot.
if ( !cellToInsert ) {
return null;
}

writer.insert( cellToInsert, insertPosition );

return cellToInsert;
}
}

/**
* Extract table for pasting into table.
*
* @private
* @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/item~Item} content The content to insert.
* @param {module:engine/model/model~Model} model The editor model.
* @returns {module:engine/model/element~Element|null}
*/
export function getTableIfOnlyTableInContent( content, model ) {
if ( !content.is( 'documentFragment' ) && !content.is( 'element' ) ) {
return null;
}

// Table passed directly.
if ( content.is( 'element', '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( 'element', 'table' ) ) {
return content.getChild( 0 );
}

// If there are only whitespaces around a table then use that table for pasting.

const contentRange = model.createRangeIn( content );

for ( const element of contentRange.getItems() ) {
if ( element.is( 'element', 'table' ) ) {
// Stop checking if there is some content before table.
const rangeBefore = model.createRange( contentRange.start, model.createPositionBefore( element ) );

if ( model.hasContent( rangeBefore, { ignoreWhitespaces: true } ) ) {
return null;
}

// Stop checking if there is some content after table.
const rangeAfter = model.createRange( model.createPositionAfter( element ), contentRange.end );

if ( model.hasContent( rangeAfter, { ignoreWhitespaces: true } ) ) {
return null;
}

// There wasn't any content neither before nor after.
return element;
}
}

return null;
}

// Prepares a table for pasting and returns adjusted selection dimensions.
Expand Down Expand Up @@ -246,109 +431,6 @@ function prepareTableForPasting( selectedTableCells, pastedDimensions, writer, t
return selection;
}

// Replaces the part of selectedTable with pastedTable.
//
// @param {module:engine/model/element~Element} pastedTable
// @param {Object} pastedDimensions
// @param {Number} pastedDimensions.height
// @param {Number} pastedDimensions.width
// @param {module:engine/model/element~Element} selectedTable
// @param {Object} selection
// @param {Number} selection.firstColumn
// @param {Number} selection.firstRow
// @param {Number} selection.lastColumn
// @param {Number} selection.lastRow
// @param {module:engine/model/writer~Writer} writer
// @returns {Array.<module:engine/model/element~Element>}
function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer ) {
const { width: pastedWidth, height: pastedHeight } = pastedDimensions;

// Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
const pastedTableLocationMap = createLocationMap( pastedTable, pastedWidth, pastedHeight );

const selectedTableMap = [ ...new TableWalker( selectedTable, {
startRow: selection.firstRow,
endRow: selection.lastRow,
startColumn: selection.firstColumn,
endColumn: selection.lastColumn,
includeAllSlots: true
} ) ];

// Selection must be set to pasted cells (some might be removed or new created).
const cellsToSelect = [];

// Store next cell insert position.
let insertPosition;

// 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 tableSlot of selectedTableMap ) {
const { row, column, cell, isAnchor } = tableSlot;

// Save the insert position for current row start.
if ( column === selection.firstColumn ) {
insertPosition = tableSlot.getPositionBefore();
}

// 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 ( isAnchor ) {
writer.remove( cell );
}

// Map current table slot location to an pasted table slot location.
const pastedRow = row - selection.firstRow;
const pastedColumn = column - selection.firstColumn;
const pastedCell = pastedTableLocationMap[ pastedRow % pastedHeight ][ pastedColumn % pastedWidth ];

// There is no cell to insert (might be spanned by other cell in a pasted table) - advance to the next content table slot.
if ( !pastedCell ) {
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 = writer.cloneElement( pastedCell );

// Trim the cell if it's row/col-spans would exceed selection area.
trimTableCellIfNeeded( cellToInsert, row, column, selection.lastRow, selection.lastColumn, writer );

writer.insert( cellToInsert, insertPosition );
cellsToSelect.push( cellToInsert );

insertPosition = writer.createPositionAfter( cellToInsert );
}

// If there are any headings, all the cells that overlap from heading must be splitted.
const headingRows = parseInt( selectedTable.getAttribute( 'headingRows' ) || 0 );
const headingColumns = parseInt( selectedTable.getAttribute( 'headingColumns' ) || 0 );

const areHeadingRowsIntersectingSelection = selection.firstRow < headingRows && headingRows <= selection.lastRow;
const areHeadingColumnsIntersectingSelection = selection.firstColumn < headingColumns && headingColumns <= selection.lastColumn;

if ( areHeadingRowsIntersectingSelection ) {
const columnsLimit = { first: selection.firstColumn, last: selection.lastColumn };
const newCells = doHorizontalSplit( selectedTable, headingRows, columnsLimit, writer, selection.firstRow );

cellsToSelect.push( ...newCells );
}

if ( areHeadingColumnsIntersectingSelection ) {
const rowsLimit = { first: selection.firstRow, last: selection.lastRow };
const newCells = doVerticalSplit( selectedTable, headingColumns, rowsLimit, writer );

cellsToSelect.push( ...newCells );
}

return cellsToSelect;
}

// Expand table (in place) to expected size.
function expandTableSize( table, expectedHeight, expectedWidth, tableUtils ) {
const tableWidth = tableUtils.getColumns( table );
Expand All @@ -369,50 +451,6 @@ function expandTableSize( table, expectedHeight, expectedWidth, tableUtils ) {
}
}

function getTableIfOnlyTableInContent( content, model ) {
if ( !content.is( 'documentFragment' ) && !content.is( 'element' ) ) {
return null;
}

// Table passed directly.
if ( content.is( 'element', '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( 'element', 'table' ) ) {
return content.getChild( 0 );
}

// If there are only whitespaces around a table then use that table for pasting.

const contentRange = model.createRangeIn( content );

for ( const element of contentRange.getItems() ) {
if ( element.is( 'element', 'table' ) ) {
// Stop checking if there is some content before table.
const rangeBefore = model.createRange( contentRange.start, model.createPositionBefore( element ) );

if ( model.hasContent( rangeBefore, { ignoreWhitespaces: true } ) ) {
return null;
}

// Stop checking if there is some content after table.
const rangeAfter = model.createRange( model.createPositionAfter( element ), contentRange.end );

if ( model.hasContent( rangeAfter, { ignoreWhitespaces: true } ) ) {
return null;
}

// There wasn't any content neither before nor after.
return element;
}
}

return null;
}

// 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:
Expand Down
8 changes: 8 additions & 0 deletions packages/ckeditor5-table/src/tableutils.js
Expand Up @@ -26,6 +26,14 @@ export default class TableUtils extends Plugin {
return 'TableUtils';
}

/**
* @inheritDoc
*/
init() {
this.decorate( 'insertColumns' );
this.decorate( 'insertRows' );
}

/**
* Returns the table cell location as an object with table row and table column indexes.
*
Expand Down

0 comments on commit f60cbd5

Please sign in to comment.