UNPKG

13.2 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
3 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4 */
5/**
6 * @module table/tableselection
7 */
8import { Plugin } from 'ckeditor5/src/core';
9import { first } from 'ckeditor5/src/utils';
10import TableWalker from './tablewalker';
11import TableUtils from './tableutils';
12import { cropTableToDimensions, adjustLastRowIndex, adjustLastColumnIndex } from './utils/structure';
13import '../theme/tableselection.css';
14/**
15 * This plugin enables the advanced table cells, rows and columns selection.
16 * It is loaded automatically by the {@link module:table/table~Table} plugin.
17 */
18export default class TableSelection extends Plugin {
19 /**
20 * @inheritDoc
21 */
22 static get pluginName() {
23 return 'TableSelection';
24 }
25 /**
26 * @inheritDoc
27 */
28 static get requires() {
29 return [TableUtils, TableUtils];
30 }
31 /**
32 * @inheritDoc
33 */
34 init() {
35 const editor = this.editor;
36 const model = editor.model;
37 const view = editor.editing.view;
38 this.listenTo(model, 'deleteContent', (evt, args) => this._handleDeleteContent(evt, args), { priority: 'high' });
39 this.listenTo(view.document, 'insertText', (evt, data) => this._handleInsertTextEvent(evt, data), { priority: 'high' });
40 this._defineSelectionConverter();
41 this._enablePluginDisabling(); // sic!
42 }
43 /**
44 * Returns the currently selected table cells or `null` if it is not a table cells selection.
45 */
46 getSelectedTableCells() {
47 const tableUtils = this.editor.plugins.get(TableUtils);
48 const selection = this.editor.model.document.selection;
49 const selectedCells = tableUtils.getSelectedTableCells(selection);
50 if (selectedCells.length == 0) {
51 return null;
52 }
53 // This should never happen, but let's know if it ever happens.
54 // @if CK_DEBUG // if ( selectedCells.length != selection.rangeCount ) {
55 // @if CK_DEBUG // console.warn( 'Mixed selection warning. The selection contains table cells and some other ranges.' );
56 // @if CK_DEBUG // }
57 return selectedCells;
58 }
59 /**
60 * Returns the selected table fragment as a document fragment.
61 */
62 getSelectionAsFragment() {
63 const tableUtils = this.editor.plugins.get(TableUtils);
64 const selectedCells = this.getSelectedTableCells();
65 if (!selectedCells) {
66 return null;
67 }
68 return this.editor.model.change(writer => {
69 const documentFragment = writer.createDocumentFragment();
70 const { first: firstColumn, last: lastColumn } = tableUtils.getColumnIndexes(selectedCells);
71 const { first: firstRow, last: lastRow } = tableUtils.getRowIndexes(selectedCells);
72 const sourceTable = selectedCells[0].findAncestor('table');
73 let adjustedLastRow = lastRow;
74 let adjustedLastColumn = lastColumn;
75 // If the selection is rectangular there could be a case of all cells in the last row/column spanned over
76 // next row/column so the real lastRow/lastColumn should be updated.
77 if (tableUtils.isSelectionRectangular(selectedCells)) {
78 const dimensions = {
79 firstColumn,
80 lastColumn,
81 firstRow,
82 lastRow
83 };
84 adjustedLastRow = adjustLastRowIndex(sourceTable, dimensions);
85 adjustedLastColumn = adjustLastColumnIndex(sourceTable, dimensions);
86 }
87 const cropDimensions = {
88 startRow: firstRow,
89 startColumn: firstColumn,
90 endRow: adjustedLastRow,
91 endColumn: adjustedLastColumn
92 };
93 const table = cropTableToDimensions(sourceTable, cropDimensions, writer);
94 writer.insert(table, documentFragment, 0);
95 return documentFragment;
96 });
97 }
98 /**
99 * Sets the model selection based on given anchor and target cells (can be the same cell).
100 * Takes care of setting the backward flag.
101 *
102 * ```ts
103 * const modelRoot = editor.model.document.getRoot();
104 * const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
105 * const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
106 *
107 * const tableSelection = editor.plugins.get( 'TableSelection' );
108 * tableSelection.setCellSelection( firstCell, lastCell );
109 * ```
110 */
111 setCellSelection(anchorCell, targetCell) {
112 const cellsToSelect = this._getCellsToSelect(anchorCell, targetCell);
113 this.editor.model.change(writer => {
114 writer.setSelection(cellsToSelect.cells.map(cell => writer.createRangeOn(cell)), { backward: cellsToSelect.backward });
115 });
116 }
117 /**
118 * Returns the focus cell from the current selection.
119 */
120 getFocusCell() {
121 const selection = this.editor.model.document.selection;
122 const focusCellRange = [...selection.getRanges()].pop();
123 const element = focusCellRange.getContainedElement();
124 if (element && element.is('element', 'tableCell')) {
125 return element;
126 }
127 return null;
128 }
129 /**
130 * Returns the anchor cell from the current selection.
131 */
132 getAnchorCell() {
133 const selection = this.editor.model.document.selection;
134 const anchorCellRange = first(selection.getRanges());
135 const element = anchorCellRange.getContainedElement();
136 if (element && element.is('element', 'tableCell')) {
137 return element;
138 }
139 return null;
140 }
141 /**
142 * Defines a selection converter which marks the selected cells with a specific class.
143 *
144 * The real DOM selection is put in the last cell. Since the order of ranges is dependent on whether the
145 * selection is backward or not, the last cell will usually be close to the "focus" end of the selection
146 * (a selection has anchor and focus).
147 *
148 * The real DOM selection is then hidden with CSS.
149 */
150 _defineSelectionConverter() {
151 const editor = this.editor;
152 const highlighted = new Set();
153 editor.conversion.for('editingDowncast').add(dispatcher => dispatcher.on('selection', (evt, data, conversionApi) => {
154 const viewWriter = conversionApi.writer;
155 clearHighlightedTableCells(viewWriter);
156 const selectedCells = this.getSelectedTableCells();
157 if (!selectedCells) {
158 return;
159 }
160 for (const tableCell of selectedCells) {
161 const viewElement = conversionApi.mapper.toViewElement(tableCell);
162 viewWriter.addClass('ck-editor__editable_selected', viewElement);
163 highlighted.add(viewElement);
164 }
165 const lastViewCell = conversionApi.mapper.toViewElement(selectedCells[selectedCells.length - 1]);
166 viewWriter.setSelection(lastViewCell, 0);
167 }, { priority: 'lowest' }));
168 function clearHighlightedTableCells(viewWriter) {
169 for (const previouslyHighlighted of highlighted) {
170 viewWriter.removeClass('ck-editor__editable_selected', previouslyHighlighted);
171 }
172 highlighted.clear();
173 }
174 }
175 /**
176 * Creates a listener that reacts to changes in {@link #isEnabled} and, if the plugin was disabled,
177 * it collapses the multi-cell selection to a regular selection placed inside a table cell.
178 *
179 * This listener helps features that disable the table selection plugin bring the selection
180 * to a clear state they can work with (for instance, because they don't support multiple cell selection).
181 */
182 _enablePluginDisabling() {
183 const editor = this.editor;
184 this.on('change:isEnabled', () => {
185 if (!this.isEnabled) {
186 const selectedCells = this.getSelectedTableCells();
187 if (!selectedCells) {
188 return;
189 }
190 editor.model.change(writer => {
191 const position = writer.createPositionAt(selectedCells[0], 0);
192 const range = editor.model.schema.getNearestSelectionRange(position);
193 writer.setSelection(range);
194 });
195 }
196 });
197 }
198 /**
199 * Overrides the default `model.deleteContent()` behavior over a selected table fragment.
200 *
201 * @param args Delete content method arguments.
202 */
203 _handleDeleteContent(event, args) {
204 const tableUtils = this.editor.plugins.get(TableUtils);
205 const selection = args[0];
206 const options = args[1];
207 const model = this.editor.model;
208 const isBackward = !options || options.direction == 'backward';
209 const selectedTableCells = tableUtils.getSelectedTableCells(selection);
210 if (!selectedTableCells.length) {
211 return;
212 }
213 event.stop();
214 model.change(writer => {
215 const tableCellToSelect = selectedTableCells[isBackward ? selectedTableCells.length - 1 : 0];
216 model.change(writer => {
217 for (const tableCell of selectedTableCells) {
218 model.deleteContent(writer.createSelection(tableCell, 'in'));
219 }
220 });
221 const rangeToSelect = model.schema.getNearestSelectionRange(writer.createPositionAt(tableCellToSelect, 0));
222 // Note: we ignore the case where rangeToSelect may be null because deleteContent() will always (unless someone broke it)
223 // create an empty paragraph to accommodate the selection.
224 if (selection.is('documentSelection')) {
225 writer.setSelection(rangeToSelect);
226 }
227 else {
228 selection.setTo(rangeToSelect);
229 }
230 });
231 }
232 /**
233 * This handler makes it possible to remove the content of all selected cells by starting to type.
234 * If you take a look at {@link #_defineSelectionConverter} you will find out that despite the multi-cell selection being set
235 * in the model, the view selection is collapsed in the last cell (because most browsers are unable to render multi-cell selections;
236 * yes, it's a hack).
237 *
238 * When multiple cells are selected in the model and the user starts to type, the
239 * {@link module:engine/view/document~Document#event:insertText} event carries information provided by the
240 * beforeinput DOM event, that in turn only knows about this collapsed DOM selection in the last cell.
241 *
242 * As a result, the selected cells have no chance to be cleaned up. To fix this, this listener intercepts
243 * the event and injects the custom view selection in the data that translates correctly to the actual state
244 * of the multi-cell selection in the model.
245 *
246 * @param data Insert text event data.
247 */
248 _handleInsertTextEvent(evt, data) {
249 const editor = this.editor;
250 const selectedCells = this.getSelectedTableCells();
251 if (!selectedCells) {
252 return;
253 }
254 const view = editor.editing.view;
255 const mapper = editor.editing.mapper;
256 const viewRanges = selectedCells.map(tableCell => view.createRangeOn(mapper.toViewElement(tableCell)));
257 data.selection = view.createSelection(viewRanges);
258 }
259 /**
260 * Returns an array of table cells that should be selected based on the
261 * given anchor cell and target (focus) cell.
262 *
263 * The cells are returned in a reverse direction if the selection is backward.
264 */
265 _getCellsToSelect(anchorCell, targetCell) {
266 const tableUtils = this.editor.plugins.get('TableUtils');
267 const startLocation = tableUtils.getCellLocation(anchorCell);
268 const endLocation = tableUtils.getCellLocation(targetCell);
269 const startRow = Math.min(startLocation.row, endLocation.row);
270 const endRow = Math.max(startLocation.row, endLocation.row);
271 const startColumn = Math.min(startLocation.column, endLocation.column);
272 const endColumn = Math.max(startLocation.column, endLocation.column);
273 // 2-dimensional array of the selected cells to ease flipping the order of cells for backward selections.
274 const selectionMap = new Array(endRow - startRow + 1).fill(null).map(() => []);
275 const walkerOptions = {
276 startRow,
277 endRow,
278 startColumn,
279 endColumn
280 };
281 for (const { row, cell } of new TableWalker(anchorCell.findAncestor('table'), walkerOptions)) {
282 selectionMap[row - startRow].push(cell);
283 }
284 const flipVertically = endLocation.row < startLocation.row;
285 const flipHorizontally = endLocation.column < startLocation.column;
286 if (flipVertically) {
287 selectionMap.reverse();
288 }
289 if (flipHorizontally) {
290 selectionMap.forEach(row => row.reverse());
291 }
292 return {
293 cells: selectionMap.flat(),
294 backward: flipVertically || flipHorizontally
295 };
296 }
297}