UNPKG

@ckeditor/ckeditor5-table

Version:

Table feature for CKEditor 5.

304 lines (303 loc) • 13.3 kB
/** * @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 }; } }