| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509 |
- /**
- * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
- /**
- * @module table/utils
- */
- import { isWidget, toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
- import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './commands/utils';
- import TableWalker from './tablewalker';
- /**
- * Converts a given {@link module:engine/view/element~Element} to a table widget:
- * * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to recognize the table widget element.
- * * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator.
- *
- * @param {module:engine/view/element~Element} viewElement
- * @param {module:engine/view/downcastwriter~DowncastWriter} writer An instance of the view writer.
- * @param {String} label The element's label. It will be concatenated with the table `alt` attribute if one is present.
- * @returns {module:engine/view/element~Element}
- */
- export function toTableWidget( viewElement, writer ) {
- writer.setCustomProperty( 'table', true, viewElement );
- return toWidget( viewElement, writer, { hasSelectionHandle: true } );
- }
- /**
- * Checks if a given view element is a table widget.
- *
- * @param {module:engine/view/element~Element} viewElement
- * @returns {Boolean}
- */
- export function isTableWidget( viewElement ) {
- return !!viewElement.getCustomProperty( 'table' ) && isWidget( viewElement );
- }
- /**
- * Returns a table widget editing view element if one is selected.
- *
- * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection} selection
- * @returns {module:engine/view/element~Element|null}
- */
- export function getSelectedTableWidget( selection ) {
- const viewElement = selection.getSelectedElement();
- if ( viewElement && isTableWidget( viewElement ) ) {
- return viewElement;
- }
- return null;
- }
- /**
- * Returns a table widget editing view element if one is among the selection's ancestors.
- *
- * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection} selection
- * @returns {module:engine/view/element~Element|null}
- */
- export function getTableWidgetAncestor( selection ) {
- const parentTable = findAncestor( 'table', selection.getFirstPosition() );
- if ( parentTable && isTableWidget( parentTable.parent ) ) {
- return parentTable.parent;
- }
- return null;
- }
- /**
- * Returns all model table cells that are fully selected (from the outside)
- * within the provided model selection's ranges.
- *
- * To obtain the cells selected from the inside, use
- * {@link module:table/utils~getTableCellsContainingSelection}.
- *
- * @param {module:engine/model/selection~Selection} selection
- * @returns {Array.<module:engine/model/element~Element>}
- */
- export function getSelectedTableCells( selection ) {
- const cells = [];
- for ( const range of sortRanges( selection.getRanges() ) ) {
- const element = range.getContainedElement();
- if ( element && element.is( 'tableCell' ) ) {
- cells.push( element );
- }
- }
- return cells;
- }
- /**
- * Returns all model table cells that the provided model selection's ranges
- * {@link module:engine/model/range~Range#start} inside.
- *
- * To obtain the cells selected from the outside, use
- * {@link module:table/utils~getSelectedTableCells}.
- *
- * @param {module:engine/model/selection~Selection} selection
- * @returns {Array.<module:engine/model/element~Element>}
- */
- export function getTableCellsContainingSelection( selection ) {
- const cells = [];
- for ( const range of selection.getRanges() ) {
- const cellWithSelection = findAncestor( 'tableCell', range.start );
- if ( cellWithSelection ) {
- cells.push( cellWithSelection );
- }
- }
- return cells;
- }
- /**
- * Returns all model table cells that are either completely selected
- * by selection ranges or host selection range
- * {@link module:engine/model/range~Range#start start positions} inside them.
- *
- * Combines {@link module:table/utils~getTableCellsContainingSelection} and
- * {@link module:table/utils~getSelectedTableCells}.
- *
- * @param {module:engine/model/selection~Selection} selection
- * @returns {Array.<module:engine/model/element~Element>}
- */
- export function getSelectionAffectedTableCells( selection ) {
- const selectedCells = getSelectedTableCells( selection );
- if ( selectedCells.length ) {
- return selectedCells;
- }
- return getTableCellsContainingSelection( selection );
- }
- /**
- * Returns an object with the `first` and `last` row index contained in the given `tableCells`.
- *
- * const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
- *
- * const { first, last } = getRowIndexes( selectedTableCells );
- *
- * console.log( `Selected rows: ${ first } to ${ last }` );
- *
- * @param {Array.<module:engine/model/element~Element>} tableCells
- * @returns {Object} Returns an object with the `first` and `last` table row indexes.
- */
- export function getRowIndexes( tableCells ) {
- const indexes = tableCells.map( cell => cell.parent.index );
- return getFirstLastIndexesObject( indexes );
- }
- /**
- * Returns an object with the `first` and `last` column index contained in the given `tableCells`.
- *
- * const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
- *
- * const { first, last } = getColumnIndexes( selectedTableCells );
- *
- * console.log( `Selected columns: ${ first } to ${ last }` );
- *
- * @param {Array.<module:engine/model/element~Element>} tableCells
- * @returns {Object} Returns an object with the `first` and `last` table column indexes.
- */
- export function getColumnIndexes( tableCells ) {
- const table = findAncestor( 'table', tableCells[ 0 ] );
- const tableMap = [ ...new TableWalker( table ) ];
- const indexes = tableMap
- .filter( entry => tableCells.includes( entry.cell ) )
- .map( entry => entry.column );
- 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.<module:engine/model/element~Element>} 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;
- }
- // TODO: refactor it to a better, general util.
- export function cutCellsHorizontallyAt( table, headingRowsToSet, currentHeadingRows, writer, boundingBox ) {
- const overlappingCells = getHorizontallyOverlappingCells( table, headingRowsToSet, currentHeadingRows );
- let cellsToSplit;
- if ( boundingBox === undefined ) {
- cellsToSplit = overlappingCells;
- } else {
- cellsToSplit = overlappingCells.filter( filterToBoundingBox( boundingBox ) );
- }
- for ( const { cell } of cellsToSplit ) {
- splitHorizontally( cell, headingRowsToSet, writer );
- }
- }
- // TODO: refactor it to a better, general util.
- export function cutCellsVerticallyAt( table, headingColumnsToSet, currentHeadingColumns, writer, boundingBox ) {
- const overlappingCells = getVerticallyOverlappingCells( table, headingColumnsToSet, currentHeadingColumns );
- let cellsToSplit;
- if ( boundingBox === undefined ) {
- cellsToSplit = overlappingCells;
- } else {
- cellsToSplit = overlappingCells.filter( filterToBoundingBox( boundingBox ) );
- }
- for ( const { cell, column } of cellsToSplit ) {
- splitVertically( cell, column, headingColumnsToSet, writer );
- }
- }
- // TODO: better fit to bounding box to match criteria.. should check also spans because sometimes we need to split them.
- function filterToBoundingBox( boundingBox ) {
- const { firstRow, firstColumn, lastRow, lastColumn } = boundingBox;
- return ( { row, column } ) => {
- return ( ( firstRow <= row ) && ( row <= lastRow ) ) && ( firstColumn <= column && column <= lastColumn );
- };
- }
- // Returns cells that span beyond the new heading section.
- //
- // @param {module:engine/model/element~Element} table The table to check.
- // @param {Number} headingRowsToSet New heading rows attribute.
- // @param {Number} currentHeadingRows Current heading rows attribute.
- // @returns {Array.<module:engine/model/element~Element>}
- function getHorizontallyOverlappingCells( table, headingRowsToSet, currentHeadingRows ) {
- const cellsToSplit = [];
- const startAnalysisRow = headingRowsToSet > currentHeadingRows ? currentHeadingRows : 0;
- // We're analyzing only when headingRowsToSet > 0.
- const endAnalysisRow = headingRowsToSet - 1;
- const tableWalker = new TableWalker( table, { startRow: startAnalysisRow, endRow: endAnalysisRow } );
- for ( const twv of tableWalker ) {
- const { row, rowspan } = twv;
- if ( rowspan > 1 && row + rowspan > headingRowsToSet ) {
- cellsToSplit.push( twv );
- }
- }
- return cellsToSplit;
- }
- // Splits the table cell horizontally.
- //
- // @param {module:engine/model/element~Element} tableCell
- // @param {Number} headingRows
- // @param {module:engine/model/writer~Writer} writer
- function splitHorizontally( tableCell, headingRows, writer ) {
- const tableRow = tableCell.parent;
- const table = tableRow.parent;
- const rowIndex = tableRow.index;
- const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) );
- const newRowspan = headingRows - rowIndex;
- const attributes = {};
- const spanToSet = rowspan - newRowspan;
- if ( spanToSet > 1 ) {
- attributes.rowspan = spanToSet;
- }
- const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
- if ( colspan > 1 ) {
- attributes.colspan = colspan;
- }
- const startRow = table.getChildIndex( tableRow );
- const endRow = startRow + newRowspan;
- const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeSpanned: true } ) ];
- let columnIndex;
- for ( const { row, column, cell, cellIndex } of tableMap ) {
- if ( cell === tableCell && columnIndex === undefined ) {
- columnIndex = column;
- }
- if ( columnIndex !== undefined && columnIndex === column && row === endRow ) {
- const tableRow = table.getChild( row );
- const tableCellPosition = writer.createPositionAt( tableRow, cellIndex );
- createEmptyTableCell( writer, tableCellPosition, attributes );
- }
- }
- // Update the rowspan attribute after updating table.
- updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer );
- }
- // Returns cells that span beyond the new heading section.
- //
- // @param {module:engine/model/element~Element} table The table to check.
- // @param {Number} headingColumnsToSet New heading columns attribute.
- // @param {Number} currentHeadingColumns Current heading columns attribute.
- // @returns {Array.<module:engine/model/element~Element>}
- function getVerticallyOverlappingCells( table, headingColumnsToSet, currentHeadingColumns ) {
- const cellsToSplit = [];
- const startAnalysisColumn = headingColumnsToSet > currentHeadingColumns ? currentHeadingColumns : 0;
- // We're analyzing only when headingColumnsToSet > 0.
- const endAnalysisColumn = headingColumnsToSet - 1;
- // todo: end/start column
- const tableWalker = new TableWalker( table );
- for ( const twv of tableWalker ) {
- const { column, colspan } = twv;
- // Skip slots outside the cropped area.
- // Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785.
- if ( startAnalysisColumn > column || column > endAnalysisColumn ) {
- continue;
- }
- if ( colspan > 1 && column + colspan > headingColumnsToSet ) {
- cellsToSplit.push( twv );
- }
- }
- return cellsToSplit;
- }
- // Splits the table cell vertically.
- //
- // @param {module:engine/model/element~Element} tableCell
- // @param {Number} headingColumns
- // @param {module:engine/model/writer~Writer} writer
- function splitVertically( tableCell, columnIndex, headingColumns, writer ) {
- const colspan = parseInt( tableCell.getAttribute( 'colspan' ) );
- const newColspan = headingColumns - columnIndex;
- const attributes = {};
- const spanToSet = colspan - newColspan;
- if ( spanToSet > 1 ) {
- attributes.colspan = spanToSet;
- }
- const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
- if ( rowspan > 1 ) {
- attributes.rowspan = rowspan;
- }
- createEmptyTableCell( writer, writer.createPositionAfter( tableCell ), attributes );
- // Update the colspan attribute after updating table.
- updateNumericAttribute( 'colspan', newColspan, tableCell, writer );
- }
- // Helper method to get an object with `first` and `last` indexes from an unsorted array of indexes.
- function getFirstLastIndexesObject( indexes ) {
- const allIndexesSorted = indexes.sort( ( indexA, indexB ) => indexA - indexB );
- const first = allIndexesSorted[ 0 ];
- const last = allIndexesSorted[ allIndexesSorted.length - 1 ];
- return { first, last };
- }
- function sortRanges( rangesIterator ) {
- return Array.from( rangesIterator ).sort( compareRangeOrder );
- }
- function compareRangeOrder( rangeA, rangeB ) {
- // Since table cell ranges are disjoint, it's enough to check their start positions.
- const posA = rangeA.start;
- const posB = rangeB.start;
- // Checking for equal position (returning 0) is not needed because this would be either:
- // a. Intersecting range (not allowed by model)
- // 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.<Number>} rows
- // @param {Array.<Number>} 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;
- }
|