8
0
Pārlūkot izejas kodu

Table navigation using 'keydown' event's instead of `keystrokes`. Added handling of widgets at beginning/end of table cell. Code cleanup and added inline comments.

Kuba Niegowski 5 gadi atpakaļ
vecāks
revīzija
01bfbf816e
1 mainītis faili ar 184 papildinājumiem un 68 dzēšanām
  1. 184 68
      packages/ckeditor5-table/src/tablenavigation.js

+ 184 - 68
packages/ckeditor5-table/src/tablenavigation.js

@@ -13,6 +13,9 @@ import { findAncestor } from './commands/utils';
 import TableWalker from './tablewalker';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 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.
@@ -32,16 +35,19 @@ export default class TableNavigation extends Plugin {
 	 * @inheritDoc
 	 */
 	init() {
+		const view = this.editor.editing.view;
+		const viewDocument = view.document;
+
 		// Handle Tab key navigation.
 		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( '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
-	 * @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
-	 * @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
 			// 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'
 			// 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 domRange = domConverter.viewRangeToDom( viewRange );
 		const rects = Rect.getDomRangeRects( domRange );
 
-		const isForward = direction == 'up';
 		let boundaryVerticalPosition = undefined;
 
 		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 ];
 
+			// We need to check if current `rect` is container for following Rects.
 			const nextRect = rects.find( ( rect, i ) => i > idx && rect.width > 0 );
 
-			// First let's skip container Rects.
 			if ( nextRect && rect.contains( nextRect ) ) {
 				continue;
 			}
 
 			if ( boundaryVerticalPosition === undefined ) {
-				boundaryVerticalPosition = Math.round( isForward ? rect.bottom : rect.top );
+				boundaryVerticalPosition = Math.round( isForward ? rect.top : rect.bottom );
 				continue;
 			}
 
 			// Let's check if this rect is in new line.
 			if ( isForward ) {
-				if ( Math.round( rect.top ) >= boundaryVerticalPosition ) {
+				if ( Math.round( rect.bottom ) <= boundaryVerticalPosition ) {
 					return false;
 				}
 
-				boundaryVerticalPosition = Math.max( boundaryVerticalPosition, rect.bottom );
+				boundaryVerticalPosition = Math.min( boundaryVerticalPosition, rect.top );
 			} else {
-				if ( Math.round( rect.bottom ) <= boundaryVerticalPosition ) {
+				if ( Math.round( rect.top ) >= boundaryVerticalPosition ) {
 					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
 	 * @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 ) {
 		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 ) {
 	switch ( direction ) {
 		case 'left': return selection.isCollapsed && selection.focus.isTouching( cellRange.start );