UNPKG

@ckeditor/ckeditor5-widget

Version:
197 lines (196 loc) • 8.54 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 widget/verticalnavigation */ import { keyCodes, Rect } from '@ckeditor/ckeditor5-utils'; /** * Returns 'keydown' handler for up/down arrow keys that modifies the caret movement if it's in a text line next to an object. * * @param editing The editing controller. */ export function verticalWidgetNavigationHandler(editing) { const model = editing.model; return (evt, data) => { const arrowUpPressed = data.keyCode == keyCodes.arrowup; const arrowDownPressed = data.keyCode == keyCodes.arrowdown; const expandSelection = data.shiftKey; const selection = model.document.selection; if (!arrowUpPressed && !arrowDownPressed) { return; } const isForward = arrowDownPressed; // Navigation is in the opposite direction than the selection direction so this is shrinking of the selection. // Selection for sure will not approach any object. if (expandSelection && selectionWillShrink(selection, isForward)) { return; } // Find a range between selection and closest limit element. const range = findTextRangeFromSelection(editing, selection, isForward); // There is no selection position inside the limit element. if (!range) { return; } // If already at the edge of a limit element. if (range.isCollapsed) { // A collapsed selection at limit edge - nothing more to do. if (selection.isCollapsed) { return; } // A non collapsed selection is at the limit edge while expanding the selection - let others do their stuff. else if (expandSelection) { return; } } // If the range is a single line (there is no word wrapping) then move the selection to the position closest to the limit element. // // We can't move the selection directly to the isObject element (eg. table 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). if (range.isCollapsed || isSingleLineRange(editing, range, isForward)) { model.change(writer => { const newPosition = isForward ? range.end : range.start; if (expandSelection) { const newSelection = model.createSelection(selection.anchor); newSelection.setFocus(newPosition); writer.setSelection(newSelection); } else { writer.setSelection(newPosition); } }); evt.stop(); data.preventDefault(); data.stopPropagation(); } }; } /** * Finds the range between selection and closest limit element (in the direction of navigation). * The position next to limit element is adjusted to the closest allowed `$text` position. * * Returns `null` if, according to the schema, the resulting range cannot contain a `$text` element. * * @param editing The editing controller. * @param selection The current selection. * @param isForward The expected navigation direction. */ function findTextRangeFromSelection(editing, selection, isForward) { const model = editing.model; if (isForward) { const startPosition = selection.isCollapsed ? selection.focus : selection.getLastPosition(); const endPosition = getNearestNonInlineLimit(model, startPosition, 'forward'); // There is no limit element, browser should handle this. if (!endPosition) { return null; } const range = model.createRange(startPosition, endPosition); const lastRangePosition = getNearestTextPosition(model.schema, range, 'backward'); if (lastRangePosition) { return model.createRange(startPosition, lastRangePosition); } return null; } else { const endPosition = selection.isCollapsed ? selection.focus : selection.getFirstPosition(); const startPosition = getNearestNonInlineLimit(model, endPosition, 'backward'); // There is no limit element, browser should handle this. if (!startPosition) { return null; } const range = model.createRange(startPosition, endPosition); const firstRangePosition = getNearestTextPosition(model.schema, range, 'forward'); if (firstRangePosition) { return model.createRange(firstRangePosition, endPosition); } return null; } } /** * Finds the limit element position that is closest to startPosition. * * @param direction Search direction. */ function getNearestNonInlineLimit(model, startPosition, direction) { const schema = model.schema; const range = model.createRangeIn(startPosition.root); const walkerValueType = direction == 'forward' ? 'elementStart' : 'elementEnd'; for (const { previousPosition, item, type } of range.getWalker({ startPosition, direction })) { if (schema.isLimit(item) && !schema.isInline(item)) { return previousPosition; } // Stop looking for isLimit element if the next element is a block element (it is for sure not single line). if (type == walkerValueType && schema.isBlock(item)) { return null; } } return null; } /** * Basing on the provided range, finds the first or last (depending on `direction`) position inside the range * that can contain `$text` (according to schema). * * @param schema The schema. * @param range The range to find the position in. * @param direction Search direction. * @returns The nearest selection position. * */ function getNearestTextPosition(schema, range, direction) { const position = direction == 'backward' ? range.end : range.start; if (schema.checkChild(position, '$text')) { return position; } for (const { nextPosition } of range.getWalker({ direction })) { if (schema.checkChild(nextPosition, '$text')) { return nextPosition; } } return null; } /** * Checks if the DOM range corresponding to the provided model range renders as a single line by analyzing DOMRects * (verifying if they visually wrap content to the next line). * * @param editing The editing controller. * @param modelRange The current table cell content range. * @param isForward The expected navigation direction. */ function isSingleLineRange(editing, modelRange, isForward) { const model = editing.model; const domConverter = editing.view.domConverter; // 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. if (isForward) { const probe = model.createSelection(modelRange.start); model.modifySelection(probe); // If the new position is at the end of the container then we can't use this position // because it would provide incorrect result for eg caption of image and selection // just before end of it. Also in this case there is no "dual" position. if (!probe.focus.isAtEnd && !modelRange.start.isEqual(probe.focus)) { modelRange = model.createRange(probe.focus, modelRange.end); } } const viewRange = editing.mapper.toViewRange(modelRange); const domRange = domConverter.viewRangeToDom(viewRange); const rects = Rect.getDomRangeRects(domRange); let boundaryVerticalPosition; for (const rect of rects) { if (boundaryVerticalPosition === undefined) { boundaryVerticalPosition = Math.round(rect.bottom); continue; } // Let's check if this rect is in new line. if (Math.round(rect.top) >= boundaryVerticalPosition) { return false; } boundaryVerticalPosition = Math.max(boundaryVerticalPosition, Math.round(rect.bottom)); } return true; } function selectionWillShrink(selection, isForward) { return !selection.isCollapsed && selection.isBackward == isForward; }