8
0

modifyselection.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module engine/model/utils/modifyselection
  7. */
  8. import Position from '../position';
  9. import TreeWalker from '../treewalker';
  10. import Range from '../range';
  11. import { isInsideSurrogatePair, isInsideCombinedSymbol } from '@ckeditor/ckeditor5-utils/src/unicode';
  12. import DocumentSelection from '../documentselection';
  13. const wordBoundaryCharacters = ' ,.?!:;"-()';
  14. /**
  15. * Modifies the selection. Currently, the supported modifications are:
  16. *
  17. * * Extending. The selection focus is moved in the specified `options.direction` with a step specified in `options.unit`.
  18. * Possible values for `unit` are:
  19. * * `'character'` (default) - moves selection by one user-perceived character. In most cases this means moving by one
  20. * character in `String` sense. However, unicode also defines "combing marks". These are special symbols, that combines
  21. * with a symbol before it ("base character") to create one user-perceived character. For example, `q̣̇` is a normal
  22. * letter `q` with two "combining marks": upper dot (`Ux0307`) and lower dot (`Ux0323`). For most actions, i.e. extending
  23. * selection by one position, it is correct to include both "base character" and all of it's "combining marks". That is
  24. * why `'character'` value is most natural and common method of modifying selection.
  25. * * `'codePoint'` - moves selection by one unicode code point. In contrary to, `'character'` unit, this will insert
  26. * selection between "base character" and "combining mark", because "combining marks" have their own unicode code points.
  27. * However, for technical reasons, unicode code points with values above `UxFFFF` are represented in native `String` by
  28. * two characters, called "surrogate pairs". Halves of "surrogate pairs" have a meaning only when placed next to each other.
  29. * For example `𨭎` is represented in `String` by `\uD862\uDF4E`. Both `\uD862` and `\uDF4E` do not have any meaning
  30. * outside the pair (are rendered as ? when alone). Position between them would be incorrect. In this case, selection
  31. * extension will include whole "surrogate pair".
  32. * * `'word'` - moves selection by a whole word.
  33. *
  34. * **Note:** if you extend a forward selection in a backward direction you will in fact shrink it.
  35. *
  36. * **Note:** Use {@link module:engine/model/model~Model#modifySelection} instead of this function.
  37. * This function is only exposed to be reusable in algorithms
  38. * which change the {@link module:engine/model/model~Model#modifySelection}
  39. * method's behavior.
  40. *
  41. * @param {module:engine/model/model~Model} model The model in context of which
  42. * the selection modification should be performed.
  43. * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
  44. * The selection to modify.
  45. * @param {Object} [options]
  46. * @param {'forward'|'backward'} [options.direction='forward'] The direction in which the selection should be modified.
  47. * @param {'character'|'codePoint'|'word'} [options.unit='character'] The unit by which selection should be modified.
  48. */
  49. export default function modifySelection( model, selection, options = {} ) {
  50. const schema = model.schema;
  51. const isForward = options.direction != 'backward';
  52. const unit = options.unit ? options.unit : 'character';
  53. const focus = selection.focus;
  54. const walker = new TreeWalker( {
  55. boundaries: getSearchRange( focus, isForward ),
  56. singleCharacters: true,
  57. direction: isForward ? 'forward' : 'backward'
  58. } );
  59. const data = { walker, schema, isForward, unit };
  60. let next;
  61. while ( ( next = walker.next() ) ) {
  62. if ( next.done ) {
  63. return;
  64. }
  65. const position = tryExtendingTo( data, next.value );
  66. if ( position ) {
  67. if ( selection instanceof DocumentSelection ) {
  68. model.change( writer => {
  69. writer.setSelectionFocus( position );
  70. } );
  71. } else {
  72. selection.setFocus( position );
  73. }
  74. return;
  75. }
  76. }
  77. }
  78. // Checks whether the selection can be extended to the the walker's next value (next position).
  79. // @param {{ walker, unit, isForward, schema }} data
  80. // @param {module:engine/view/treewalker~TreeWalkerValue} value
  81. function tryExtendingTo( data, value ) {
  82. // If found text, we can certainly put the focus in it. Let's just find a correct position
  83. // based on the unit.
  84. if ( value.type == 'text' ) {
  85. if ( data.unit === 'word' ) {
  86. return getCorrectWordBreakPosition( data.walker, data.isForward );
  87. }
  88. return getCorrectPosition( data.walker, data.unit, data.isForward );
  89. }
  90. // Entering an element.
  91. if ( value.type == ( data.isForward ? 'elementStart' : 'elementEnd' ) ) {
  92. // If it's an object, we can select it now.
  93. if ( data.schema.isObject( value.item ) ) {
  94. return Position._createAt( value.item, data.isForward ? 'after' : 'before' );
  95. }
  96. // If text allowed on this position, extend to this place.
  97. if ( data.schema.checkChild( value.nextPosition, '$text' ) ) {
  98. return value.nextPosition;
  99. }
  100. }
  101. // Leaving an element.
  102. else {
  103. // If leaving a limit element, stop.
  104. if ( data.schema.isLimit( value.item ) ) {
  105. // NOTE: Fast-forward the walker until the end.
  106. data.walker.skip( () => true );
  107. return;
  108. }
  109. // If text allowed on this position, extend to this place.
  110. if ( data.schema.checkChild( value.nextPosition, '$text' ) ) {
  111. return value.nextPosition;
  112. }
  113. }
  114. }
  115. // Finds a correct position by walking in a text node and checking whether selection can be extended to given position
  116. // or should be extended further.
  117. //
  118. // @param {module:engine/model/treewalker~TreeWalker} walker
  119. // @param {String} unit The unit by which selection should be modified.
  120. function getCorrectPosition( walker, unit ) {
  121. const textNode = walker.position.textNode;
  122. if ( textNode ) {
  123. const data = textNode.data;
  124. let offset = walker.position.offset - textNode.startOffset;
  125. while ( isInsideSurrogatePair( data, offset ) || ( unit == 'character' && isInsideCombinedSymbol( data, offset ) ) ) {
  126. walker.next();
  127. offset = walker.position.offset - textNode.startOffset;
  128. }
  129. }
  130. return walker.position;
  131. }
  132. // Finds a correct position of a word break by walking in a text node and checking whether selection can be extended to given position
  133. // or should be extended further.
  134. //
  135. // @param {module:engine/model/treewalker~TreeWalker} walker
  136. // @param {Boolean} isForward Is the direction in which the selection should be modified is forward.
  137. function getCorrectWordBreakPosition( walker, isForward ) {
  138. let textNode = walker.position.textNode;
  139. if ( textNode ) {
  140. let offset = walker.position.offset - textNode.startOffset;
  141. while ( !isAtWordBoundary( textNode.data, offset, isForward ) && !isAtNodeBoundary( textNode, offset, isForward ) ) {
  142. walker.next();
  143. // Check of adjacent text nodes with different attributes (like BOLD).
  144. // Example : 'foofoo []bar<$text bold="true">bar</$text> bazbaz'
  145. // should expand to : 'foofoo [bar<$text bold="true">bar</$text>] bazbaz'.
  146. const nextNode = isForward ? walker.position.nodeAfter : walker.position.nodeBefore;
  147. // Scan only text nodes. Ignore inline elements (like `<softBreak>`).
  148. if ( nextNode && nextNode.is( '$text' ) ) {
  149. // Check boundary char of an adjacent text node.
  150. const boundaryChar = nextNode.data.charAt( isForward ? 0 : nextNode.data.length - 1 );
  151. // Go to the next node if the character at the boundary of that node belongs to the same word.
  152. if ( !wordBoundaryCharacters.includes( boundaryChar ) ) {
  153. // If adjacent text node belongs to the same word go to it & reset values.
  154. walker.next();
  155. textNode = walker.position.textNode;
  156. }
  157. }
  158. offset = walker.position.offset - textNode.startOffset;
  159. }
  160. }
  161. return walker.position;
  162. }
  163. function getSearchRange( start, isForward ) {
  164. const root = start.root;
  165. const searchEnd = Position._createAt( root, isForward ? 'end' : 0 );
  166. if ( isForward ) {
  167. return new Range( start, searchEnd );
  168. } else {
  169. return new Range( searchEnd, start );
  170. }
  171. }
  172. // Checks if selection is on word boundary.
  173. //
  174. // @param {String} data The text node value to investigate.
  175. // @param {Number} offset Position offset.
  176. // @param {Boolean} isForward Is the direction in which the selection should be modified is forward.
  177. function isAtWordBoundary( data, offset, isForward ) {
  178. // The offset to check depends on direction.
  179. const offsetToCheck = offset + ( isForward ? 0 : -1 );
  180. return wordBoundaryCharacters.includes( data.charAt( offsetToCheck ) );
  181. }
  182. // Checks if selection is on node boundary.
  183. //
  184. // @param {module:engine/model/text~Text} textNode The text node to investigate.
  185. // @param {Number} offset Position offset.
  186. // @param {Boolean} isForward Is the direction in which the selection should be modified is forward.
  187. function isAtNodeBoundary( textNode, offset, isForward ) {
  188. return offset === ( isForward ? textNode.endOffset : 0 );
  189. }