|
@@ -13,6 +13,9 @@ import { findAncestor } from './commands/utils';
|
|
|
import TableWalker from './tablewalker';
|
|
import TableWalker from './tablewalker';
|
|
|
import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
|
|
import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
|
|
|
import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range';
|
|
import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range';
|
|
|
|
|
+import TreeWalker from '@ckeditor/ckeditor5-engine/src/model/treewalker';
|
|
|
|
|
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
|
|
|
|
|
+import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* This plugin enables a keyboard navigation for tables.
|
|
* This plugin enables a keyboard navigation for tables.
|
|
@@ -32,16 +35,19 @@ export default class TableNavigation extends Plugin {
|
|
|
* @inheritDoc
|
|
* @inheritDoc
|
|
|
*/
|
|
*/
|
|
|
init() {
|
|
init() {
|
|
|
|
|
+ const view = this.editor.editing.view;
|
|
|
|
|
+ const viewDocument = view.document;
|
|
|
|
|
+
|
|
|
// Handle Tab key navigation.
|
|
// Handle Tab key navigation.
|
|
|
this.editor.keystrokes.set( 'Tab', ( ...args ) => this._handleTabOnSelectedTable( ...args ), { priority: 'low' } );
|
|
this.editor.keystrokes.set( 'Tab', ( ...args ) => this._handleTabOnSelectedTable( ...args ), { priority: 'low' } );
|
|
|
this.editor.keystrokes.set( 'Tab', this._getTabHandler( true ), { priority: 'low' } );
|
|
this.editor.keystrokes.set( 'Tab', this._getTabHandler( true ), { priority: 'low' } );
|
|
|
this.editor.keystrokes.set( 'Shift+Tab', this._getTabHandler( false ), { priority: 'low' } );
|
|
this.editor.keystrokes.set( 'Shift+Tab', this._getTabHandler( false ), { priority: 'low' } );
|
|
|
|
|
|
|
|
- // Handle arrows navigation.
|
|
|
|
|
- this.editor.keystrokes.set( 'ArrowLeft', this._getArrowHandler( 'left' ), { priority: 'low' } );
|
|
|
|
|
- this.editor.keystrokes.set( 'ArrowRight', this._getArrowHandler( 'right' ), { priority: 'low' } );
|
|
|
|
|
- this.editor.keystrokes.set( 'ArrowUp', this._getArrowHandler( 'up' ), { priority: 'low' } );
|
|
|
|
|
- this.editor.keystrokes.set( 'ArrowDown', this._getArrowHandler( 'down' ), { priority: 'low' } );
|
|
|
|
|
|
|
+ // Note: This listener has the "high+1" priority because we would like to avoid collisions with other features
|
|
|
|
|
+ // (like Widgets), which take over the keydown events with the "high" priority. Table navigation takes precedence
|
|
|
|
|
+ // over Widgets in that matter (widget arrow handler stops propagation of event if object element was selected
|
|
|
|
|
+ // but getNearestSelectionRange didn't returned any range).
|
|
|
|
|
+ this.listenTo( viewDocument, 'keydown', ( ...args ) => this._onKeydown( ...args ), { priority: priorities.get( 'high' ) + 1 } );
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -143,125 +149,204 @@ export default class TableNavigation extends Plugin {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Returns a handler for {@link module:engine/view/document~Document#event:keydown keydown} events for the arrow keys executed
|
|
|
|
|
- * inside table cell.
|
|
|
|
|
|
|
+ * Handles {@link module:engine/view/document~Document#event:keydown keydown} events.
|
|
|
*
|
|
*
|
|
|
* @private
|
|
* @private
|
|
|
- * @param {String} direction The direction of navigation relative to the cell in which the caret is located.
|
|
|
|
|
- * Possible values: `"left"`, `"right"`, `"up"` and `"down"`.
|
|
|
|
|
|
|
+ * @param {module:utils/eventinfo~EventInfo} eventInfo
|
|
|
|
|
+ * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
|
|
|
*/
|
|
*/
|
|
|
- _getArrowHandler( direction ) {
|
|
|
|
|
- return ( data, cancel ) => {
|
|
|
|
|
- const model = this.editor.model;
|
|
|
|
|
- const selection = model.document.selection;
|
|
|
|
|
|
|
+ _onKeydown( eventInfo, domEventData ) {
|
|
|
|
|
+ const isLtrContent = this.editor.locale.contentLanguageDirection === 'ltr';
|
|
|
|
|
+ const keyCode = domEventData.keyCode;
|
|
|
|
|
+ let wasHandled = false;
|
|
|
|
|
+
|
|
|
|
|
+ // Checks if the keys were handled and then prevents the default event behaviour and stops
|
|
|
|
|
+ // the propagation.
|
|
|
|
|
+ if ( isArrowKeyCode( keyCode ) ) {
|
|
|
|
|
+ wasHandled = this._handleArrowKeys( getDirectionFromKeyCode( keyCode, isLtrContent ) );
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // At first let's check if there are some cells that are fully selected (from the outside).
|
|
|
|
|
- const selectedCells = getSelectedTableCells( selection );
|
|
|
|
|
|
|
+ if ( wasHandled ) {
|
|
|
|
|
+ domEventData.preventDefault();
|
|
|
|
|
+ eventInfo.stop();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- if ( selectedCells.length ) {
|
|
|
|
|
- const tableCell = [ 'left', 'up' ].includes( direction ) ? selectedCells[ 0 ] : selectedCells.pop();
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Handles arrow keys.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @private
|
|
|
|
|
+ * @param {'left'|'up'|'right'|'down'} direction The direction of the arrow key.
|
|
|
|
|
+ * @returns {Boolean|undefined} Returns `true` if key was handled.
|
|
|
|
|
+ */
|
|
|
|
|
+ _handleArrowKeys( direction ) {
|
|
|
|
|
+ const model = this.editor.model;
|
|
|
|
|
+ const selection = model.document.selection;
|
|
|
|
|
+ const isForward = [ 'right', 'down' ].includes( direction );
|
|
|
|
|
|
|
|
- this._navigateFromCellInDirection( tableCell, direction );
|
|
|
|
|
|
|
+ // At first let's check if there are some cells that are fully selected (from the outside).
|
|
|
|
|
+ const selectedCells = getSelectedTableCells( selection );
|
|
|
|
|
|
|
|
- cancel();
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if ( selectedCells.length ) {
|
|
|
|
|
+ const tableCell = isForward ? selectedCells.pop() : selectedCells[ 0 ];
|
|
|
|
|
|
|
|
- // So we fall back to selection inside the table cell.
|
|
|
|
|
- const tableCell = findAncestor( 'tableCell', selection.focus );
|
|
|
|
|
|
|
+ this._navigateFromCellInDirection( tableCell, direction );
|
|
|
|
|
|
|
|
- // But the selection is outside the table.
|
|
|
|
|
- if ( !tableCell ) {
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- const cellRange = model.createRangeIn( tableCell );
|
|
|
|
|
|
|
+ // So we fall back to selection inside the table cell.
|
|
|
|
|
+ const tableCell = findAncestor( 'tableCell', selection.focus );
|
|
|
|
|
|
|
|
- // Let's check if the selection is at the beginning/end of the cell.
|
|
|
|
|
- if ( isSelectionAtCellEdge( cellRange, selection, direction ) ) {
|
|
|
|
|
- this._navigateFromCellInDirection( tableCell, direction );
|
|
|
|
|
|
|
+ // But the selection is outside the table.
|
|
|
|
|
+ if ( !tableCell ) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- cancel();
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const cellRange = model.createRangeIn( tableCell );
|
|
|
|
|
|
|
|
- // Ok, so easiest cases didn't solved the problem, let's try to find out if we are in the first/last
|
|
|
|
|
- // line of the cell content, and if so we will move the caret to beginning/end.
|
|
|
|
|
|
|
+ // Let's check if the selection is at the beginning/end of the cell.
|
|
|
|
|
+ if ( isSelectionAtCellEdge( cellRange, selection, direction ) ) {
|
|
|
|
|
+ this._navigateFromCellInDirection( tableCell, direction );
|
|
|
|
|
|
|
|
- if ( [ 'up', 'down' ].includes( direction ) && this._isNavigationInEdgeLine( cellRange, direction ) ) {
|
|
|
|
|
- model.change( writer => {
|
|
|
|
|
- writer.setSelection( direction == 'up' ? cellRange.start : cellRange.end );
|
|
|
|
|
- } );
|
|
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- cancel();
|
|
|
|
|
- }
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ // Ok, so easiest cases didn't solved the problem, let's try to find out if we are in the first/last
|
|
|
|
|
+ // line of the cell content, or if an object element is selected at beginning/end of range.
|
|
|
|
|
+ //
|
|
|
|
|
+ // We try to find a nearest text position that is not before/after the selection. If there is no
|
|
|
|
|
+ // such range, there is some object element at the beginning/end of range.
|
|
|
|
|
+ const textRange = this._findTextRangeFromSelection( cellRange, selection, isForward );
|
|
|
|
|
+
|
|
|
|
|
+ if ( !textRange ) {
|
|
|
|
|
+ this._navigateFromCellInDirection( tableCell, direction );
|
|
|
|
|
+
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // There wasn't any object element so let's check if range is a single line. If it's a single line
|
|
|
|
|
+ // then move selection to the beginning/end of a cell content.
|
|
|
|
|
+ //
|
|
|
|
|
+ // We can't move the selection directly to other cell because of dual position at the end/beginning
|
|
|
|
|
+ // of wrapped line (it's at the same time at the end of one line and at the start of the next line).
|
|
|
|
|
+ const isVertical = [ 'up', 'down' ].includes( direction );
|
|
|
|
|
+
|
|
|
|
|
+ if ( isVertical && this._isSingleLineRange( textRange, isForward ) ) {
|
|
|
|
|
+ model.change( writer => {
|
|
|
|
|
+ writer.setSelection( isForward ? cellRange.end : cellRange.start );
|
|
|
|
|
+ } );
|
|
|
|
|
+
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Detects if a keyboard navigation is on the first/last row of the table cell.
|
|
|
|
|
|
|
+ * Returns a range from beginning/end of `range` up to `selection` or `null` if resulting range can't contain
|
|
|
|
|
+ * text element (according to schema).
|
|
|
*
|
|
*
|
|
|
* @private
|
|
* @private
|
|
|
- * @param {module:engine/model/range~Range} cellRange Current table cell content range.
|
|
|
|
|
- * @param {String} direction The direction of navigation relative to the cell in which the caret is located.
|
|
|
|
|
- * Possible values: `"up"` and `"down"`.
|
|
|
|
|
- * @returns {Boolean} Whether navigation was handled.
|
|
|
|
|
|
|
+ * @param {module:engine/model/range~Range} range Current table cell content range.
|
|
|
|
|
+ * @param {module:engine/model/selection~Selection} selection The current selection.
|
|
|
|
|
+ * @param {Boolean} isForward The expected navigation direction.
|
|
|
|
|
+ * @returns {module:engine/model/range~Range|null}
|
|
|
*/
|
|
*/
|
|
|
- _isNavigationInEdgeLine( cellRange, direction ) {
|
|
|
|
|
- const model = this.editor.model;
|
|
|
|
|
- const selection = model.document.selection;
|
|
|
|
|
- const editing = this.editor.editing;
|
|
|
|
|
- const domConverter = editing.view.domConverter;
|
|
|
|
|
|
|
+ _findTextRangeFromSelection( range, selection, isForward ) {
|
|
|
|
|
+ if ( isForward ) {
|
|
|
|
|
+ const position = selection.getLastPosition();
|
|
|
|
|
+ const lastRangePosition = this._getNearestTextPosition( range, 'backward' );
|
|
|
|
|
|
|
|
- let modelRange;
|
|
|
|
|
|
|
+ if ( !lastRangePosition || position.compareWith( lastRangePosition ) != 'before' ) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- if ( direction == 'up' ) {
|
|
|
|
|
- modelRange = new ModelRange( cellRange.start, selection.getFirstPosition() );
|
|
|
|
|
- } else {
|
|
|
|
|
// Wrapped lines contain exactly the same position at the end of current line
|
|
// Wrapped lines contain exactly the same position at the end of current line
|
|
|
// and at the beginning of next line. That position's client rect is at the end
|
|
// and at the beginning of next line. That position's client rect is at the end
|
|
|
// of current line. In case of caret at first position of the last line that 'dual'
|
|
// of current line. In case of caret at first position of the last line that 'dual'
|
|
|
// position would be detected as it's not the last line.
|
|
// position would be detected as it's not the last line.
|
|
|
- const position = selection.getLastPosition();
|
|
|
|
|
- modelRange = new ModelRange( position.isAtEnd ? position : position.getShiftedBy( 1 ), cellRange.end );
|
|
|
|
|
|
|
+ return new ModelRange( position.isAtEnd ? position : position.getShiftedBy( 1 ), lastRangePosition );
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const position = selection.getFirstPosition();
|
|
|
|
|
+ const firstRangePosition = this._getNearestTextPosition( range, 'forward' );
|
|
|
|
|
+
|
|
|
|
|
+ if ( !firstRangePosition || position.compareWith( firstRangePosition ) != 'after' ) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return new ModelRange( firstRangePosition, position );
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Basing on provided `boundaries` range, finds first/last (depending on `direction`) element
|
|
|
|
|
+ * that can contain `$text` (according to schema).
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {module:engine/model/range~Range} boundaries The range to find position in.
|
|
|
|
|
+ * @param {'forward'|'backward'} direction Search direction.
|
|
|
|
|
+ * @returns {module:engine/model/position~Position|null} Nearest selection range or `null` if one cannot be found.
|
|
|
|
|
+ */
|
|
|
|
|
+ _getNearestTextPosition( boundaries, direction ) {
|
|
|
|
|
+ const schema = this.editor.model.schema;
|
|
|
|
|
+ const startPosition = direction == 'forward' ? boundaries.start : boundaries.end;
|
|
|
|
|
+
|
|
|
|
|
+ const treeWalker = new TreeWalker( { direction, boundaries, startPosition } );
|
|
|
|
|
+
|
|
|
|
|
+ for ( const { nextPosition } of treeWalker ) {
|
|
|
|
|
+ if ( schema.checkChild( nextPosition, '$text' ) ) {
|
|
|
|
|
+ return nextPosition;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Checks if `modelRange` is a single line.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @private
|
|
|
|
|
+ * @param {module:engine/model/range~Range} modelRange Current table cell content range.
|
|
|
|
|
+ * @param {Boolean} isForward The expected navigation direction.
|
|
|
|
|
+ * @returns {Boolean}
|
|
|
|
|
+ */
|
|
|
|
|
+ _isSingleLineRange( modelRange, isForward ) {
|
|
|
|
|
+ const editing = this.editor.editing;
|
|
|
|
|
+ const domConverter = editing.view.domConverter;
|
|
|
|
|
+
|
|
|
const viewRange = editing.mapper.toViewRange( modelRange );
|
|
const viewRange = editing.mapper.toViewRange( modelRange );
|
|
|
const domRange = domConverter.viewRangeToDom( viewRange );
|
|
const domRange = domConverter.viewRangeToDom( viewRange );
|
|
|
const rects = Rect.getDomRangeRects( domRange );
|
|
const rects = Rect.getDomRangeRects( domRange );
|
|
|
|
|
|
|
|
- const isForward = direction == 'up';
|
|
|
|
|
let boundaryVerticalPosition = undefined;
|
|
let boundaryVerticalPosition = undefined;
|
|
|
|
|
|
|
|
for ( let i = 0; i < rects.length; i++ ) {
|
|
for ( let i = 0; i < rects.length; i++ ) {
|
|
|
- const idx = isForward ? i : rects.length - i - 1;
|
|
|
|
|
|
|
+ const idx = isForward ? rects.length - i - 1 : i;
|
|
|
const rect = rects[ idx ];
|
|
const rect = rects[ idx ];
|
|
|
|
|
|
|
|
|
|
+ // We need to check if current `rect` is container for following Rects.
|
|
|
const nextRect = rects.find( ( rect, i ) => i > idx && rect.width > 0 );
|
|
const nextRect = rects.find( ( rect, i ) => i > idx && rect.width > 0 );
|
|
|
|
|
|
|
|
- // First let's skip container Rects.
|
|
|
|
|
if ( nextRect && rect.contains( nextRect ) ) {
|
|
if ( nextRect && rect.contains( nextRect ) ) {
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if ( boundaryVerticalPosition === undefined ) {
|
|
if ( boundaryVerticalPosition === undefined ) {
|
|
|
- boundaryVerticalPosition = Math.round( isForward ? rect.bottom : rect.top );
|
|
|
|
|
|
|
+ boundaryVerticalPosition = Math.round( isForward ? rect.top : rect.bottom );
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Let's check if this rect is in new line.
|
|
// Let's check if this rect is in new line.
|
|
|
if ( isForward ) {
|
|
if ( isForward ) {
|
|
|
- if ( Math.round( rect.top ) >= boundaryVerticalPosition ) {
|
|
|
|
|
|
|
+ if ( Math.round( rect.bottom ) <= boundaryVerticalPosition ) {
|
|
|
return false;
|
|
return false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- boundaryVerticalPosition = Math.max( boundaryVerticalPosition, rect.bottom );
|
|
|
|
|
|
|
+ boundaryVerticalPosition = Math.min( boundaryVerticalPosition, rect.top );
|
|
|
} else {
|
|
} else {
|
|
|
- if ( Math.round( rect.bottom ) <= boundaryVerticalPosition ) {
|
|
|
|
|
|
|
+ if ( Math.round( rect.top ) >= boundaryVerticalPosition ) {
|
|
|
return false;
|
|
return false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- boundaryVerticalPosition = Math.min( boundaryVerticalPosition, rect.top );
|
|
|
|
|
|
|
+ boundaryVerticalPosition = Math.max( boundaryVerticalPosition, rect.bottom );
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -273,7 +358,7 @@ export default class TableNavigation extends Plugin {
|
|
|
*
|
|
*
|
|
|
* @private
|
|
* @private
|
|
|
* @param {module:engine/model/element~Element} tableCell The table cell to start the selection navigation.
|
|
* @param {module:engine/model/element~Element} tableCell The table cell to start the selection navigation.
|
|
|
- * @param {String} direction Direction in which selection should move.
|
|
|
|
|
|
|
+ * @param {'left'|'up'|'right'|'down'} direction Direction in which selection should move.
|
|
|
*/
|
|
*/
|
|
|
_navigateFromCellInDirection( tableCell, direction ) {
|
|
_navigateFromCellInDirection( tableCell, direction ) {
|
|
|
const model = this.editor.model;
|
|
const model = this.editor.model;
|
|
@@ -336,6 +421,37 @@ export default class TableNavigation extends Plugin {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// Returns 'true' if provided key code represents one of the arrow keys.
|
|
|
|
|
+//
|
|
|
|
|
+// @param {Number} keyCode
|
|
|
|
|
+// @returns {Boolean}
|
|
|
|
|
+function isArrowKeyCode( keyCode ) {
|
|
|
|
|
+ return keyCode == keyCodes.arrowright ||
|
|
|
|
|
+ keyCode == keyCodes.arrowleft ||
|
|
|
|
|
+ keyCode == keyCodes.arrowup ||
|
|
|
|
|
+ keyCode == keyCodes.arrowdown;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Returns direction name from `keyCode`.
|
|
|
|
|
+//
|
|
|
|
|
+// @param {Number} keyCode
|
|
|
|
|
+// @param {Boolean} isLtrContent The content language direction.
|
|
|
|
|
+// @returns {'left'|'up'|'right'|'down'} Arrow direction.
|
|
|
|
|
+function getDirectionFromKeyCode( keyCode, isLtrContent ) {
|
|
|
|
|
+ switch ( keyCode ) {
|
|
|
|
|
+ case keyCodes.arrowleft: return isLtrContent ? 'left' : 'right';
|
|
|
|
|
+ case keyCodes.arrowright: return isLtrContent ? 'right' : 'left';
|
|
|
|
|
+ case keyCodes.arrowup: return 'up';
|
|
|
|
|
+ case keyCodes.arrowdown: return 'down';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Returns `true` if `selection` is at `cellRange` edge according to navigation `direction`.
|
|
|
|
|
+//
|
|
|
|
|
+// @param {module:engine/model/range~Range} cellRange The bounding cell range.
|
|
|
|
|
+// @param {module:engine/model/selection~Selection} selection The current selection.
|
|
|
|
|
+// @param {'left'|'up'|'right'|'down'} direction The expected navigation direction.
|
|
|
|
|
+// @returns {Boolean}
|
|
|
function isSelectionAtCellEdge( cellRange, selection, direction ) {
|
|
function isSelectionAtCellEdge( cellRange, selection, direction ) {
|
|
|
switch ( direction ) {
|
|
switch ( direction ) {
|
|
|
case 'left': return selection.isCollapsed && selection.focus.isTouching( cellRange.start );
|
|
case 'left': return selection.isCollapsed && selection.focus.isTouching( cellRange.start );
|