selection-post-fixer.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. /**
  2. * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module engine/model/utils/selection-post-fixer
  7. */
  8. import Range from '../range';
  9. import Position from '../position';
  10. /**
  11. * Injects selection post-fixer to the model.
  12. *
  13. * The role of the selection post-fixer is to ensure that the selection is in a correct place
  14. * after a {@link module:engine/model/model~Model#change `change()`} block was executed.
  15. *
  16. * The correct position means that:
  17. *
  18. * * All collapsed selection ranges are in a place where the {@link module:engine/model/schema~Schema}
  19. * allows a `$text`.
  20. * * None of the selection's non-collapsed ranges crosses a {@link module:engine/model/schema~Schema#isLimit limit element}
  21. * boundary (a range must be rooted within one limit element).
  22. * * Only {@link module:engine/model/schema~Schema#isObject object elements} can be selected from the outside
  23. * (e.g. `[<paragraph>foo</paragraph>]` is invalid). This rule applies independently to both selection ends, so this
  24. * selection is correct: `<paragraph>f[oo</paragraph><image></image>]`.
  25. *
  26. * If the position is not correct, the post-fixer will automatically correct it.
  27. *
  28. * ## Fixing a non-collapsed selection
  29. *
  30. * See as an example a selection that starts in a P1 element and ends inside the text of a TD element
  31. * (`[` and `]` are range boundaries and `(l)` denotes an element defined as `isLimit=true`):
  32. *
  33. * root
  34. * |- element P1
  35. * | |- "foo" root
  36. * |- element TABLE (l) P1 TABLE P2
  37. * | |- element TR (l) f o[o TR TR b a r
  38. * | | |- element TD (l) TD TD
  39. * | | |- "aaa" a]a a b b b
  40. * | |- element TR (l)
  41. * | | |- element TD (l) ||
  42. * | | |- "bbb" ||
  43. * |- element P2 VV
  44. * | |- "bar"
  45. * root
  46. * P1 TABLE] P2
  47. * f o[o TR TR b a r
  48. * TD TD
  49. * a a a b b b
  50. *
  51. * In the example above, the TABLE, TR and TD are defined as `isLimit=true` in the schema. The range which is not contained within
  52. * a single limit element must be expanded to select the outermost limit element. The range end is inside the text node of the TD element.
  53. * As the TD element is a child of the TR and TABLE elements, where both are defined as `isLimit=true` in the schema, the range must be
  54. * expanded to select the whole TABLE element.
  55. *
  56. * **Note** If the selection contains multiple ranges, the method returns a minimal set of ranges that are not intersecting after expanding
  57. * them to select `isLimit=true` elements.
  58. *
  59. * @param {module:engine/model/model~Model} model
  60. */
  61. export function injectSelectionPostFixer( model ) {
  62. model.document.registerPostFixer( writer => selectionPostFixer( writer, model ) );
  63. }
  64. // The selection post-fixer.
  65. //
  66. // @param {module:engine/model/writer~Writer} writer
  67. // @param {module:engine/model/model~Model} model
  68. function selectionPostFixer( writer, model ) {
  69. const selection = model.document.selection;
  70. const schema = model.schema;
  71. const ranges = [];
  72. let wasFixed = false;
  73. for ( const modelRange of selection.getRanges() ) {
  74. // Go through all ranges in selection and try fixing each of them.
  75. // Those ranges might overlap but will be corrected later.
  76. const correctedRange = tryFixingRange( modelRange, schema );
  77. if ( correctedRange ) {
  78. ranges.push( correctedRange );
  79. wasFixed = true;
  80. } else {
  81. ranges.push( modelRange );
  82. }
  83. }
  84. // If any of ranges were corrected update the selection.
  85. if ( wasFixed ) {
  86. // The above algorithm might create ranges that intersects each other when selection contains more then one range.
  87. // This is case happens mostly on Firefox which creates multiple ranges for selected table.
  88. let fixedRanges = ranges;
  89. // Fixing selection with many ranges usually breaks the selection in Firefox. As only Firefox supports multiple selection ranges
  90. // we simply create one continuous range from fixed selection ranges (even if they are not adjacent).
  91. if ( ranges.length > 1 ) {
  92. const selectionStart = ranges[ 0 ].start;
  93. const selectionEnd = ranges[ ranges.length - 1 ].end;
  94. fixedRanges = [ new Range( selectionStart, selectionEnd ) ];
  95. }
  96. writer.setSelection( fixedRanges, { backward: selection.isBackward } );
  97. }
  98. }
  99. // Tries fixing a range if it's incorrect.
  100. //
  101. // @param {module:engine/model/range~Range} range
  102. // @param {module:engine/model/schema~Schema} schema
  103. // @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid.
  104. function tryFixingRange( range, schema ) {
  105. if ( range.isCollapsed ) {
  106. return tryFixingCollapsedRange( range, schema );
  107. }
  108. return tryFixingNonCollapsedRage( range, schema );
  109. }
  110. // Tries to fix collapsed ranges.
  111. //
  112. // * Fixes situation when a range is in a place where $text is not allowed
  113. //
  114. // @param {module:engine/model/range~Range} range Collapsed range to fix.
  115. // @param {module:engine/model/schema~Schema} schema
  116. // @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid.
  117. function tryFixingCollapsedRange( range, schema ) {
  118. const originalPosition = range.start;
  119. const nearestSelectionRange = schema.getNearestSelectionRange( originalPosition );
  120. // This might be null ie when editor data is empty.
  121. // In such cases there is no need to fix the selection range.
  122. if ( !nearestSelectionRange ) {
  123. return null;
  124. }
  125. const fixedPosition = nearestSelectionRange.start;
  126. // Fixed position is the same as original - no need to return corrected range.
  127. if ( originalPosition.isEqual( fixedPosition ) ) {
  128. return null;
  129. }
  130. // Check single node selection (happens in tables).
  131. if ( fixedPosition.nodeAfter && schema.isLimit( fixedPosition.nodeAfter ) ) {
  132. return new Range( fixedPosition, Position.createAfter( fixedPosition.nodeAfter ) );
  133. }
  134. return new Range( fixedPosition );
  135. }
  136. // Tries to fix an expanded range.
  137. //
  138. // @param {module:engine/model/range~Range} range Expanded range to fix.
  139. // @param {module:engine/model/schema~Schema} schema
  140. // @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid.
  141. function tryFixingNonCollapsedRage( range, schema ) {
  142. const start = range.start;
  143. const end = range.end;
  144. const isTextAllowedOnStart = schema.checkChild( start, '$text' );
  145. const isTextAllowedOnEnd = schema.checkChild( end, '$text' );
  146. const startLimitElement = schema.getLimitElement( start );
  147. const endLimitElement = schema.getLimitElement( end );
  148. // Ranges which both end are inside the same limit element (or root) might needs only minor fix.
  149. if ( startLimitElement === endLimitElement ) {
  150. // Range is valid when both position allows to place a text:
  151. // - <block>f[oobarba]z</block>
  152. // This would be "fixed" by a next check but as it will be the same it's better to return null so the selection stays the same.
  153. if ( isTextAllowedOnStart && isTextAllowedOnEnd ) {
  154. return null;
  155. }
  156. // Range that is on non-limit element (or is partially) must be fixed so it is placed inside the block around $text:
  157. // - [<block>foo</block>] -> <block>[foo]</block>
  158. // - [<block>foo]</block> -> <block>[foo]</block>
  159. // - <block>f[oo</block>] -> <block>f[oo]</block>
  160. if ( checkSelectionOnNonLimitElements( start, end, schema ) ) {
  161. const fixedStart = schema.getNearestSelectionRange( start, 'forward' );
  162. const fixedEnd = schema.getNearestSelectionRange( end, 'backward' );
  163. return new Range( fixedStart ? fixedStart.start : start, fixedEnd ? fixedEnd.start : end );
  164. }
  165. }
  166. const isStartInLimit = startLimitElement && !startLimitElement.is( 'rootElement' );
  167. const isEndInLimit = endLimitElement && !endLimitElement.is( 'rootElement' );
  168. // At this point we eliminated valid positions on text nodes so if one of range positions is placed inside a limit element
  169. // then the range crossed limit element boundaries and needs to be fixed.
  170. if ( isStartInLimit || isEndInLimit ) {
  171. // Although we've already found limit element on start/end positions we must find the outer-most limit element.
  172. // as limit elements might be nested directly inside (ie table > tableRow > tableCell).
  173. const startPosition = Position.createAt( startLimitElement, 0 );
  174. const endPosition = Position.createAt( endLimitElement, 0 );
  175. const fixedStart = isStartInLimit ? expandSelectionOnIsLimitNode( startPosition, schema, 'start' ) : start;
  176. const fixedEnd = isEndInLimit ? expandSelectionOnIsLimitNode( endPosition, schema, 'end' ) : end;
  177. return new Range( fixedStart, fixedEnd );
  178. }
  179. // Range was not fixed at this point so it is valid - ie it was placed around limit element already.
  180. return null;
  181. }
  182. // Expands selection so it contains whole limit node.
  183. //
  184. // @param {module:engine/model/position~Position} position
  185. // @param {module:engine/model/schema~Schema} schema
  186. // @param {String} expandToDirection Direction of expansion - either 'start' or 'end' of the range.
  187. // @returns {module:engine/model/position~Position}
  188. function expandSelectionOnIsLimitNode( position, schema, expandToDirection ) {
  189. let node = position.parent;
  190. let parent = node;
  191. // Find outer most isLimit block as such blocks might be nested (ie. in tables).
  192. while ( schema.isLimit( parent ) && parent.parent ) {
  193. node = parent;
  194. parent = parent.parent;
  195. }
  196. // Depending on direction of expanding selection return position before or after found node.
  197. return expandToDirection === 'start' ? Position.createBefore( node ) : Position.createAfter( node );
  198. }
  199. // Checks whether both range ends are placed around non-limit elements.
  200. //
  201. // @param {module:engine/model/position~Position} start
  202. // @param {module:engine/model/position~Position} end
  203. // @param {module:engine/model/schema~Schema} schema
  204. function checkSelectionOnNonLimitElements( start, end, schema ) {
  205. const startIsOnBlock = ( start.nodeAfter && !schema.isLimit( start.nodeAfter ) ) || schema.checkChild( start, '$text' );
  206. const endIsOnBlock = ( end.nodeBefore && !schema.isLimit( end.nodeBefore ) ) || schema.checkChild( end, '$text' );
  207. return startIsOnBlock && endIsOnBlock;
  208. }