/** * @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/tableselection */ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import first from '@ckeditor/ckeditor5-utils/src/first'; import TableWalker from './tablewalker'; import TableUtils from './tableutils'; import { cropTableToDimensions, adjustLastRowIndex, adjustLastColumnIndex } from './utils/structure'; import { getColumnIndexes, getRowIndexes, getSelectedTableCells, isSelectionRectangular } from './utils/selection'; import '../theme/tableselection.css'; /** * This plugin enables the advanced table cells, rows and columns selection. * It is loaded automatically by the {@link module:table/table~Table} plugin. * * @extends module:core/plugin~Plugin */ export default class TableSelection extends Plugin { /** * @inheritDoc */ static get pluginName() { return 'TableSelection'; } /** * @inheritDoc */ static get requires() { return [ TableUtils ]; } /** * @inheritDoc */ init() { const editor = this.editor; const model = editor.model; this.listenTo( model, 'deleteContent', ( evt, args ) => this._handleDeleteContent( evt, args ), { priority: 'high' } ); this._defineSelectionConverter(); this._enablePluginDisabling(); // sic! } /** * Returns the currently selected table cells or `null` if it is not a table cells selection. * * @returns {Array.|null} */ getSelectedTableCells() { const selection = this.editor.model.document.selection; const selectedCells = getSelectedTableCells( selection ); if ( selectedCells.length == 0 ) { return null; } // This should never happen, but let's know if it ever happens. // @if CK_DEBUG // /* istanbul ignore next */ // @if CK_DEBUG // if ( selectedCells.length != selection.rangeCount ) { // @if CK_DEBUG // console.warn( 'Mixed selection warning. The selection contains table cells and some other ranges.' ); // @if CK_DEBUG // } return selectedCells; } /** * Returns the selected table fragment as a document fragment. * * @returns {module:engine/model/documentfragment~DocumentFragment|null} */ getSelectionAsFragment() { const selectedCells = this.getSelectedTableCells(); if ( !selectedCells ) { return null; } return this.editor.model.change( writer => { const documentFragment = writer.createDocumentFragment(); const tableUtils = this.editor.plugins.get( 'TableUtils' ); const { first: firstColumn, last: lastColumn } = getColumnIndexes( selectedCells ); const { first: firstRow, last: lastRow } = getRowIndexes( selectedCells ); const sourceTable = selectedCells[ 0 ].findAncestor( 'table' ); let adjustedLastRow = lastRow; let adjustedLastColumn = lastColumn; // If the selection is rectangular there could be a case of all cells in the last row/column spanned over // next row/column so the real lastRow/lastColumn should be updated. if ( isSelectionRectangular( selectedCells, tableUtils ) ) { const dimensions = { firstColumn, lastColumn, firstRow, lastRow }; adjustedLastRow = adjustLastRowIndex( sourceTable, dimensions ); adjustedLastColumn = adjustLastColumnIndex( sourceTable, dimensions ); } const cropDimensions = { startRow: firstRow, startColumn: firstColumn, endRow: adjustedLastRow, endColumn: adjustedLastColumn }; const table = cropTableToDimensions( sourceTable, cropDimensions, writer ); writer.insert( table, documentFragment, 0 ); return documentFragment; } ); } /** * Sets the model selection based on given anchor and target cells (can be the same cell). * Takes care of setting the backward flag. * * const modelRoot = editor.model.document.getRoot(); * const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] ); * const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] ); * * const tableSelection = editor.plugins.get( 'TableSelection' ); * tableSelection.setCellSelection( firstCell, lastCell ); * * @param {module:engine/model/element~Element} anchorCell * @param {module:engine/model/element~Element} targetCell */ setCellSelection( anchorCell, targetCell ) { const cellsToSelect = this._getCellsToSelect( anchorCell, targetCell ); this.editor.model.change( writer => { writer.setSelection( cellsToSelect.cells.map( cell => writer.createRangeOn( cell ) ), { backward: cellsToSelect.backward } ); } ); } /** * Returns the focus cell from the current selection. * * @returns {module:engine/model/element~Element} */ getFocusCell() { const selection = this.editor.model.document.selection; const focusCellRange = [ ...selection.getRanges() ].pop(); const element = focusCellRange.getContainedElement(); if ( element && element.is( 'tableCell' ) ) { return element; } return null; } /** * Returns the anchor cell from the current selection. * * @returns {module:engine/model/element~Element} anchorCell */ getAnchorCell() { const selection = this.editor.model.document.selection; const anchorCellRange = first( selection.getRanges() ); const element = anchorCellRange.getContainedElement(); if ( element && element.is( 'tableCell' ) ) { return element; } return null; } /** * Defines a selection converter which marks the selected cells with a specific class. * * The real DOM selection is put in the last cell. Since the order of ranges is dependent on whether the * selection is backward or not, the last cell will usually be close to the "focus" end of the selection * (a selection has anchor and focus). * * The real DOM selection is then hidden with CSS. * * @private */ _defineSelectionConverter() { const editor = this.editor; const highlighted = new Set(); editor.conversion.for( 'editingDowncast' ).add( dispatcher => dispatcher.on( 'selection', ( evt, data, conversionApi ) => { const viewWriter = conversionApi.writer; clearHighlightedTableCells( viewWriter ); const selectedCells = this.getSelectedTableCells(); if ( !selectedCells ) { return; } for ( const tableCell of selectedCells ) { const viewElement = conversionApi.mapper.toViewElement( tableCell ); viewWriter.addClass( 'ck-editor__editable_selected', viewElement ); highlighted.add( viewElement ); } const lastViewCell = conversionApi.mapper.toViewElement( selectedCells[ selectedCells.length - 1 ] ); viewWriter.setSelection( lastViewCell, 0 ); }, { priority: 'lowest' } ) ); function clearHighlightedTableCells( writer ) { for ( const previouslyHighlighted of highlighted ) { writer.removeClass( 'ck-editor__editable_selected', previouslyHighlighted ); } highlighted.clear(); } } /** * Creates a listener that reacts to changes in {@link #isEnabled} and, if the plugin was disabled, * it collapses the multi-cell selection to a regular selection placed inside a table cell. * * This listener helps features that disable the table selection plugin bring the selection * to a clear state they can work with (for instance, because they don't support multiple cell selection). */ _enablePluginDisabling() { const editor = this.editor; this.on( 'change:isEnabled', () => { if ( !this.isEnabled ) { const selectedCells = this.getSelectedTableCells(); if ( !selectedCells ) { return; } editor.model.change( writer => { const position = writer.createPositionAt( selectedCells[ 0 ], 0 ); const range = editor.model.schema.getNearestSelectionRange( position ); writer.setSelection( range ); } ); } } ); } /** * Overrides the default `model.deleteContent()` behavior over a selected table fragment. * * @private * @param {module:utils/eventinfo~EventInfo} event * @param {Array.<*>} args Delete content method arguments. */ _handleDeleteContent( event, args ) { const [ selection, options ] = args; const model = this.editor.model; const isBackward = !options || options.direction == 'backward'; const selectedTableCells = getSelectedTableCells( selection ); if ( !selectedTableCells.length ) { return; } event.stop(); model.change( writer => { const tableCellToSelect = selectedTableCells[ isBackward ? selectedTableCells.length - 1 : 0 ]; model.change( writer => { for ( const tableCell of selectedTableCells ) { model.deleteContent( writer.createSelection( tableCell, 'in' ) ); } } ); const rangeToSelect = model.schema.getNearestSelectionRange( writer.createPositionAt( tableCellToSelect, 0 ) ); // Note: we ignore the case where rangeToSelect may be null because deleteContent() will always (unless someone broke it) // create an empty paragraph to accommodate the selection. if ( selection.is( 'documentSelection' ) ) { writer.setSelection( rangeToSelect ); } else { selection.setTo( rangeToSelect ); } } ); } /** * Returns an array of table cells that should be selected based on the * given anchor cell and target (focus) cell. * * The cells are returned in a reverse direction if the selection is backward. * * @private * @param {module:engine/model/element~Element} anchorCell * @param {module:engine/model/element~Element} targetCell * @returns {Array.} */ _getCellsToSelect( anchorCell, targetCell ) { const tableUtils = this.editor.plugins.get( 'TableUtils' ); const startLocation = tableUtils.getCellLocation( anchorCell ); const endLocation = tableUtils.getCellLocation( targetCell ); const startRow = Math.min( startLocation.row, endLocation.row ); const endRow = Math.max( startLocation.row, endLocation.row ); const startColumn = Math.min( startLocation.column, endLocation.column ); const endColumn = Math.max( startLocation.column, endLocation.column ); // 2-dimensional array of the selected cells to ease flipping the order of cells for backward selections. const selectionMap = new Array( endRow - startRow + 1 ).fill( null ).map( () => [] ); const walkerOptions = { startRow, endRow, startColumn, endColumn }; for ( const { row, cell } of new TableWalker( anchorCell.findAncestor( 'table' ), walkerOptions ) ) { selectionMap[ row - startRow ].push( cell ); } const flipVertically = endLocation.row < startLocation.row; const flipHorizontally = endLocation.column < startLocation.column; if ( flipVertically ) { selectionMap.reverse(); } if ( flipHorizontally ) { selectionMap.forEach( row => row.reverse() ); } return { cells: selectionMap.flat(), backward: flipVertically || flipHorizontally }; } }