@ckeditor/ckeditor5-table
Version:
Table feature for CKEditor 5.
304 lines (303 loc) • 13.3 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module table/tableselection
*/
import { Plugin } from 'ckeditor5/src/core.js';
import { first } from 'ckeditor5/src/utils.js';
import TableWalker from './tablewalker.js';
import TableUtils from './tableutils.js';
import { cropTableToDimensions, adjustLastRowIndex, adjustLastColumnIndex } from './utils/structure.js';
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.
*/
export default class TableSelection extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'TableSelection';
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [TableUtils, TableUtils];
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const model = editor.model;
const view = editor.editing.view;
this.listenTo(model, 'deleteContent', (evt, args) => this._handleDeleteContent(evt, args), { priority: 'high' });
this.listenTo(view.document, 'insertText', (evt, data) => this._handleInsertTextEvent(evt, data), { priority: 'high' });
this._defineSelectionConverter();
this._enablePluginDisabling(); // sic!
}
/**
* Returns the currently selected table cells or `null` if it is not a table cells selection.
*/
getSelectedTableCells() {
const tableUtils = this.editor.plugins.get(TableUtils);
const selection = this.editor.model.document.selection;
const selectedCells = tableUtils.getSelectedTableCells(selection);
if (selectedCells.length == 0) {
return null;
}
// This should never happen, but let's know if it ever happens.
// @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.
*/
getSelectionAsFragment() {
const tableUtils = this.editor.plugins.get(TableUtils);
const selectedCells = this.getSelectedTableCells();
if (!selectedCells) {
return null;
}
return this.editor.model.change(writer => {
const documentFragment = writer.createDocumentFragment();
const { first: firstColumn, last: lastColumn } = tableUtils.getColumnIndexes(selectedCells);
const { first: firstRow, last: lastRow } = tableUtils.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 (tableUtils.isSelectionRectangular(selectedCells)) {
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.
*
* ```ts
* 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 );
* ```
*/
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.
*/
getFocusCell() {
const selection = this.editor.model.document.selection;
const focusCellRange = [...selection.getRanges()].pop();
const element = focusCellRange.getContainedElement();
if (element && element.is('element', 'tableCell')) {
return element;
}
return null;
}
/**
* Returns the anchor cell from the current selection.
*/
getAnchorCell() {
const selection = this.editor.model.document.selection;
const anchorCellRange = first(selection.getRanges());
const element = anchorCellRange.getContainedElement();
if (element && element.is('element', '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.
*/
_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(viewWriter) {
for (const previouslyHighlighted of highlighted) {
viewWriter.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.
*
* @param args Delete content method arguments.
*/
_handleDeleteContent(event, args) {
const tableUtils = this.editor.plugins.get(TableUtils);
const selection = args[0];
const options = args[1];
const model = this.editor.model;
const isBackward = !options || options.direction == 'backward';
const selectedTableCells = tableUtils.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);
}
});
}
/**
* This handler makes it possible to remove the content of all selected cells by starting to type.
* If you take a look at {@link #_defineSelectionConverter} you will find out that despite the multi-cell selection being set
* in the model, the view selection is collapsed in the last cell (because most browsers are unable to render multi-cell selections;
* yes, it's a hack).
*
* When multiple cells are selected in the model and the user starts to type, the
* {@link module:engine/view/document~Document#event:insertText} event carries information provided by the
* beforeinput DOM event, that in turn only knows about this collapsed DOM selection in the last cell.
*
* As a result, the selected cells have no chance to be cleaned up. To fix this, this listener intercepts
* the event and injects the custom view selection in the data that translates correctly to the actual state
* of the multi-cell selection in the model.
*
* @param data Insert text event data.
*/
_handleInsertTextEvent(evt, data) {
const editor = this.editor;
const selectedCells = this.getSelectedTableCells();
if (!selectedCells) {
return;
}
const view = editor.editing.view;
const mapper = editor.editing.mapper;
const viewRanges = selectedCells.map(tableCell => view.createRangeOn(mapper.toViewElement(tableCell)));
data.selection = view.createSelection(viewRanges);
}
/**
* 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.
*/
_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
};
}
}