UNPKG

@ckeditor/ckeditor5-engine

Version:

The editing engine of CKEditor 5 – the best browser-based rich text editor.

188 lines (187 loc) 8.45 kB
/** * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ /** * @module engine/model/utils/modifyselection */ import { ModelDocumentSelection } from '../documentselection.js'; import { ModelPosition } from '../position.js'; import { ModelRange } from '../range.js'; import { ModelTreeWalker } from '../treewalker.js'; import { isInsideSurrogatePair, isInsideCombinedSymbol, isInsideEmojiSequence } from '@ckeditor/ckeditor5-utils'; const wordBoundaryCharacters = ' ,.?!:;"-()'; /** * Modifies the selection. Currently, the supported modifications are: * * * Extending. The selection focus is moved in the specified `options.direction` with a step specified in `options.unit`. * Possible values for `unit` are: * * `'character'` (default) - moves selection by one user-perceived character. In most cases this means moving by one * character in `String` sense. However, unicode also defines "combing marks". These are special symbols, that combines * with a symbol before it ("base character") to create one user-perceived character. For example, `q̣̇` is a normal * letter `q` with two "combining marks": upper dot (`Ux0307`) and lower dot (`Ux0323`). For most actions, i.e. extending * selection by one position, it is correct to include both "base character" and all of it's "combining marks". That is * why `'character'` value is most natural and common method of modifying selection. * * `'codePoint'` - moves selection by one unicode code point. In contrary to, `'character'` unit, this will insert * selection between "base character" and "combining mark", because "combining marks" have their own unicode code points. * However, for technical reasons, unicode code points with values above `UxFFFF` are represented in native `String` by * two characters, called "surrogate pairs". Halves of "surrogate pairs" have a meaning only when placed next to each other. * For example `𨭎` is represented in `String` by `\uD862\uDF4E`. Both `\uD862` and `\uDF4E` do not have any meaning * outside the pair (are rendered as ? when alone). Position between them would be incorrect. In this case, selection * extension will include whole "surrogate pair". * * `'word'` - moves selection by a whole word. * * **Note:** if you extend a forward selection in a backward direction you will in fact shrink it. * * **Note:** Use {@link module:engine/model/model~Model#modifySelection} instead of this function. * This function is only exposed to be reusable in algorithms * which change the {@link module:engine/model/model~Model#modifySelection} * method's behavior. * * @param model The model in context of which the selection modification should be performed. * @param selection The selection to modify. * @param options.direction The direction in which the selection should be modified. Default 'forward'. * @param options.unit The unit by which selection should be modified. Default 'character'. * @param options.treatEmojiAsSingleUnit Whether multi-characer emoji sequences should be handled as single unit. * @internal */ export function modifySelection(model, selection, options = {}) { const schema = model.schema; const isForward = options.direction != 'backward'; const unit = options.unit ? options.unit : 'character'; const treatEmojiAsSingleUnit = !!options.treatEmojiAsSingleUnit; const focus = selection.focus; const walker = new ModelTreeWalker({ boundaries: getSearchRange(focus, isForward), singleCharacters: true, direction: isForward ? 'forward' : 'backward' }); const data = { walker, schema, isForward, unit, treatEmojiAsSingleUnit }; let next; while ((next = walker.next())) { if (next.done) { return; } const position = tryExtendingTo(data, next.value); if (position) { if (selection instanceof ModelDocumentSelection) { model.change(writer => { writer.setSelectionFocus(position); }); } else { selection.setFocus(position); } return; } } } /** * Checks whether the selection can be extended to the the walker's next value (next position). */ function tryExtendingTo(data, value) { const { isForward, walker, unit, schema, treatEmojiAsSingleUnit } = data; const { type, item, nextPosition } = value; // If found text, we can certainly put the focus in it. Let's just find a correct position // based on the unit. if (type == 'text') { if (data.unit === 'word') { return getCorrectWordBreakPosition(walker, isForward); } return getCorrectPosition(walker, unit, treatEmojiAsSingleUnit); } // Entering an element. if (type == (isForward ? 'elementStart' : 'elementEnd')) { // If it's a selectable, we can select it now. if (schema.isSelectable(item)) { return ModelPosition._createAt(item, isForward ? 'after' : 'before'); } // If text allowed on this position, extend to this place. if (schema.checkChild(nextPosition, '$text')) { return nextPosition; } } // Leaving an element. else { // If leaving a limit element, stop. if (schema.isLimit(item)) { // NOTE: Fast-forward the walker until the end. walker.skip(() => true); return; } // If text allowed on this position, extend to this place. if (schema.checkChild(nextPosition, '$text')) { return nextPosition; } } } /** * Finds a correct position by walking in a text node and checking whether selection can be extended to given position * or should be extended further. */ function getCorrectPosition(walker, unit, treatEmojiAsSingleUnit) { const textNode = walker.position.textNode; if (textNode) { const data = textNode.data; let offset = walker.position.offset - textNode.startOffset; while (isInsideSurrogatePair(data, offset) || (unit == 'character' && isInsideCombinedSymbol(data, offset)) || (treatEmojiAsSingleUnit && isInsideEmojiSequence(data, offset))) { walker.next(); offset = walker.position.offset - textNode.startOffset; } } return walker.position; } /** * Finds a correct position of a word break by walking in a text node and checking whether selection can be extended to given position * or should be extended further. */ function getCorrectWordBreakPosition(walker, isForward) { let textNode = walker.position.textNode; if (!textNode) { textNode = isForward ? walker.position.nodeAfter : walker.position.nodeBefore; } while (textNode && textNode.is('$text')) { const offset = walker.position.offset - textNode.startOffset; // Check of adjacent text nodes with different attributes (like BOLD). // Example : 'foofoo []bar<$text bold="true">bar</$text> bazbaz' // should expand to : 'foofoo [bar<$text bold="true">bar</$text>] bazbaz'. if (isAtNodeBoundary(textNode, offset, isForward)) { textNode = isForward ? walker.position.nodeAfter : walker.position.nodeBefore; } // Check if this is a word boundary. else if (isAtWordBoundary(textNode.data, offset, isForward)) { break; } // Maybe one more character. else { walker.next(); } } return walker.position; } function getSearchRange(start, isForward) { const root = start.root; const searchEnd = ModelPosition._createAt(root, isForward ? 'end' : 0); if (isForward) { return new ModelRange(start, searchEnd); } else { return new ModelRange(searchEnd, start); } } /** * Checks if selection is on word boundary. */ function isAtWordBoundary(data, offset, isForward) { // The offset to check depends on direction. const offsetToCheck = offset + (isForward ? 0 : -1); return wordBoundaryCharacters.includes(data.charAt(offsetToCheck)); } /** * Checks if selection is on node boundary. */ function isAtNodeBoundary(textNode, offset, isForward) { return offset === (isForward ? textNode.offsetSize : 0); }