UNPKG

12.6 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/tablekeyboard
7 */
8import TableSelection from './tableselection';
9import TableWalker from './tablewalker';
10import TableUtils from './tableutils';
11import { Plugin } from 'ckeditor5/src/core';
12import { getLocalizedArrowKeyCodeDirection } from 'ckeditor5/src/utils';
13/**
14 * This plugin enables keyboard navigation for tables.
15 * It is loaded automatically by the {@link module:table/table~Table} plugin.
16 */
17export default class TableKeyboard extends Plugin {
18 /**
19 * @inheritDoc
20 */
21 static get pluginName() {
22 return 'TableKeyboard';
23 }
24 /**
25 * @inheritDoc
26 */
27 static get requires() {
28 return [TableSelection, TableUtils];
29 }
30 /**
31 * @inheritDoc
32 */
33 init() {
34 const view = this.editor.editing.view;
35 const viewDocument = view.document;
36 this.listenTo(viewDocument, 'arrowKey', (...args) => this._onArrowKey(...args), { context: 'table' });
37 this.listenTo(viewDocument, 'tab', (...args) => this._handleTabOnSelectedTable(...args), { context: 'figure' });
38 this.listenTo(viewDocument, 'tab', (...args) => this._handleTab(...args), { context: ['th', 'td'] });
39 }
40 /**
41 * Handles {@link module:engine/view/document~Document#event:tab tab} events for the <kbd>Tab</kbd> key executed
42 * when the table widget is selected.
43 */
44 _handleTabOnSelectedTable(bubblingEventInfo, domEventData) {
45 const editor = this.editor;
46 const selection = editor.model.document.selection;
47 const selectedElement = selection.getSelectedElement();
48 if (!selectedElement || !selectedElement.is('element', 'table')) {
49 return;
50 }
51 domEventData.preventDefault();
52 domEventData.stopPropagation();
53 bubblingEventInfo.stop();
54 editor.model.change(writer => {
55 writer.setSelection(writer.createRangeIn(selectedElement.getChild(0).getChild(0)));
56 });
57 }
58 /**
59 * Handles {@link module:engine/view/document~Document#event:tab tab} events for the <kbd>Tab</kbd> key executed
60 * inside table cells.
61 */
62 _handleTab(bubblingEventInfo, domEventData) {
63 const editor = this.editor;
64 const tableUtils = this.editor.plugins.get(TableUtils);
65 const tableSelection = this.editor.plugins.get('TableSelection');
66 const selection = editor.model.document.selection;
67 const isForward = !domEventData.shiftKey;
68 let tableCell = tableUtils.getTableCellsContainingSelection(selection)[0];
69 if (!tableCell) {
70 tableCell = tableSelection.getFocusCell();
71 }
72 if (!tableCell) {
73 return;
74 }
75 domEventData.preventDefault();
76 domEventData.stopPropagation();
77 bubblingEventInfo.stop();
78 const tableRow = tableCell.parent;
79 const table = tableRow.parent;
80 const currentRowIndex = table.getChildIndex(tableRow);
81 const currentCellIndex = tableRow.getChildIndex(tableCell);
82 const isFirstCellInRow = currentCellIndex === 0;
83 if (!isForward && isFirstCellInRow && currentRowIndex === 0) {
84 // Set the selection over the whole table if the selection was in the first table cell.
85 editor.model.change(writer => {
86 writer.setSelection(writer.createRangeOn(table));
87 });
88 return;
89 }
90 const isLastCellInRow = currentCellIndex === tableRow.childCount - 1;
91 const isLastRow = currentRowIndex === tableUtils.getRows(table) - 1;
92 if (isForward && isLastRow && isLastCellInRow) {
93 editor.execute('insertTableRowBelow');
94 // Check if the command actually added a row. If `insertTableRowBelow` execution didn't add a row (because it was disabled
95 // or it got overwritten) set the selection over the whole table to mirror the first cell case.
96 if (currentRowIndex === tableUtils.getRows(table) - 1) {
97 editor.model.change(writer => {
98 writer.setSelection(writer.createRangeOn(table));
99 });
100 return;
101 }
102 }
103 let cellToFocus;
104 // Move to the first cell in the next row.
105 if (isForward && isLastCellInRow) {
106 const nextRow = table.getChild(currentRowIndex + 1);
107 cellToFocus = nextRow.getChild(0);
108 }
109 // Move to the last cell in the previous row.
110 else if (!isForward && isFirstCellInRow) {
111 const previousRow = table.getChild(currentRowIndex - 1);
112 cellToFocus = previousRow.getChild(previousRow.childCount - 1);
113 }
114 // Move to the next/previous cell.
115 else {
116 cellToFocus = tableRow.getChild(currentCellIndex + (isForward ? 1 : -1));
117 }
118 editor.model.change(writer => {
119 writer.setSelection(writer.createRangeIn(cellToFocus));
120 });
121 }
122 /**
123 * Handles {@link module:engine/view/document~Document#event:keydown keydown} events.
124 */
125 _onArrowKey(eventInfo, domEventData) {
126 const editor = this.editor;
127 const keyCode = domEventData.keyCode;
128 const direction = getLocalizedArrowKeyCodeDirection(keyCode, editor.locale.contentLanguageDirection);
129 const wasHandled = this._handleArrowKeys(direction, domEventData.shiftKey);
130 if (wasHandled) {
131 domEventData.preventDefault();
132 domEventData.stopPropagation();
133 eventInfo.stop();
134 }
135 }
136 /**
137 * Handles arrow keys to move the selection around the table.
138 *
139 * @param direction The direction of the arrow key.
140 * @param expandSelection If the current selection should be expanded.
141 * @returns Returns `true` if key was handled.
142 */
143 _handleArrowKeys(direction, expandSelection) {
144 const tableUtils = this.editor.plugins.get(TableUtils);
145 const tableSelection = this.editor.plugins.get('TableSelection');
146 const model = this.editor.model;
147 const selection = model.document.selection;
148 const isForward = ['right', 'down'].includes(direction);
149 // In case one or more table cells are selected (from outside),
150 // move the selection to a cell adjacent to the selected table fragment.
151 const selectedCells = tableUtils.getSelectedTableCells(selection);
152 if (selectedCells.length) {
153 let focusCell;
154 if (expandSelection) {
155 focusCell = tableSelection.getFocusCell();
156 }
157 else {
158 focusCell = isForward ? selectedCells[selectedCells.length - 1] : selectedCells[0];
159 }
160 this._navigateFromCellInDirection(focusCell, direction, expandSelection);
161 return true;
162 }
163 // Abort if we're not in a table cell.
164 const tableCell = selection.focus.findAncestor('tableCell');
165 /* istanbul ignore if: paranoid check -- @preserve */
166 if (!tableCell) {
167 return false;
168 }
169 // When the selection is not collapsed.
170 if (!selection.isCollapsed) {
171 if (expandSelection) {
172 // Navigation is in the opposite direction than the selection direction so this is shrinking of the selection.
173 // Selection for sure will not approach cell edge.
174 //
175 // With a special case when all cell content is selected - then selection should expand to the other cell.
176 // Note: When the entire cell gets selected using CTRL+A, the selection is always forward.
177 if (selection.isBackward == isForward && !selection.containsEntireContent(tableCell)) {
178 return false;
179 }
180 }
181 else {
182 const selectedElement = selection.getSelectedElement();
183 // It will collapse for non-object selected so it's not going to move to other cell.
184 if (!selectedElement || !model.schema.isObject(selectedElement)) {
185 return false;
186 }
187 }
188 }
189 // Let's check if the selection is at the beginning/end of the cell.
190 if (this._isSelectionAtCellEdge(selection, tableCell, isForward)) {
191 this._navigateFromCellInDirection(tableCell, direction, expandSelection);
192 return true;
193 }
194 return false;
195 }
196 /**
197 * Returns `true` if the selection is at the boundary of a table cell according to the navigation direction.
198 *
199 * @param selection The current selection.
200 * @param tableCell The current table cell element.
201 * @param isForward The expected navigation direction.
202 */
203 _isSelectionAtCellEdge(selection, tableCell, isForward) {
204 const model = this.editor.model;
205 const schema = this.editor.model.schema;
206 const focus = isForward ? selection.getLastPosition() : selection.getFirstPosition();
207 // If the current limit element is not table cell we are for sure not at the cell edge.
208 // Also `modifySelection` will not let us out of it.
209 if (!schema.getLimitElement(focus).is('element', 'tableCell')) {
210 const boundaryPosition = model.createPositionAt(tableCell, isForward ? 'end' : 0);
211 return boundaryPosition.isTouching(focus);
212 }
213 const probe = model.createSelection(focus);
214 model.modifySelection(probe, { direction: isForward ? 'forward' : 'backward' });
215 // If there was no change in the focus position, then it's not possible to move the selection there.
216 return focus.isEqual(probe.focus);
217 }
218 /**
219 * Moves the selection from the given table cell in the specified direction.
220 *
221 * @param focusCell The table cell that is current multi-cell selection focus.
222 * @param direction Direction in which selection should move.
223 * @param expandSelection If the current selection should be expanded. Default value is false.
224 */
225 _navigateFromCellInDirection(focusCell, direction, expandSelection = false) {
226 const model = this.editor.model;
227 const table = focusCell.findAncestor('table');
228 const tableMap = [...new TableWalker(table, { includeAllSlots: true })];
229 const { row: lastRow, column: lastColumn } = tableMap[tableMap.length - 1];
230 const currentCellInfo = tableMap.find(({ cell }) => cell == focusCell);
231 let { row, column } = currentCellInfo;
232 switch (direction) {
233 case 'left':
234 column--;
235 break;
236 case 'up':
237 row--;
238 break;
239 case 'right':
240 column += currentCellInfo.cellWidth;
241 break;
242 case 'down':
243 row += currentCellInfo.cellHeight;
244 break;
245 }
246 const isOutsideVertically = row < 0 || row > lastRow;
247 const isBeforeFirstCell = column < 0 && row <= 0;
248 const isAfterLastCell = column > lastColumn && row >= lastRow;
249 // Note that if the table cell at the end of a row is row-spanned then isAfterLastCell will never be true.
250 // However, we don't know if user was navigating on the last row or not, so let's stay in the table.
251 if (isOutsideVertically || isBeforeFirstCell || isAfterLastCell) {
252 model.change(writer => {
253 writer.setSelection(writer.createRangeOn(table));
254 });
255 return;
256 }
257 if (column < 0) {
258 column = expandSelection ? 0 : lastColumn;
259 row--;
260 }
261 else if (column > lastColumn) {
262 column = expandSelection ? lastColumn : 0;
263 row++;
264 }
265 const cellToSelect = tableMap.find(cellInfo => cellInfo.row == row && cellInfo.column == column).cell;
266 const isForward = ['right', 'down'].includes(direction);
267 const tableSelection = this.editor.plugins.get('TableSelection');
268 if (expandSelection && tableSelection.isEnabled) {
269 const anchorCell = tableSelection.getAnchorCell() || focusCell;
270 tableSelection.setCellSelection(anchorCell, cellToSelect);
271 }
272 else {
273 const positionToSelect = model.createPositionAt(cellToSelect, isForward ? 0 : 'end');
274 model.change(writer => {
275 writer.setSelection(positionToSelect);
276 });
277 }
278 }
279}