8
0

selection-post-fixer.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. /**
  2. * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module engine/mode/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. const combinedRanges = combineOverlappingRanges( ranges );
  89. writer.setSelection( combinedRanges, { backward: selection.isBackward } );
  90. }
  91. }
  92. // Tries fixing a range if it's incorrect.
  93. //
  94. // @param {module:engine/model/range~Range} range
  95. // @param {module:engine/model/schema~Schema} schema
  96. // @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid.
  97. function tryFixingRange( range, schema ) {
  98. if ( range.isCollapsed ) {
  99. return tryFixingCollapsedRange( range, schema );
  100. }
  101. return tryFixingNonCollapsedRage( range, schema );
  102. }
  103. // Tries to fix collapsed ranges.
  104. //
  105. // * Fixes situation when a range is in a place where $text is not allowed
  106. //
  107. // @param {module:engine/model/range~Range} range Collapsed range to fix.
  108. // @param {module:engine/model/schema~Schema} schema
  109. // @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid.
  110. function tryFixingCollapsedRange( range, schema ) {
  111. const originalPosition = range.start;
  112. const nearestSelectionRange = schema.getNearestSelectionRange( originalPosition );
  113. // This might be null ie when editor data is empty.
  114. // In such cases there is no need to fix the selection range.
  115. if ( !nearestSelectionRange ) {
  116. return null;
  117. }
  118. const fixedPosition = nearestSelectionRange.start;
  119. // Fixed position is the same as original - no need to return corrected range.
  120. if ( originalPosition.isEqual( fixedPosition ) ) {
  121. return null;
  122. }
  123. // Check single node selection (happens in tables).
  124. if ( fixedPosition.nodeAfter && schema.isLimit( fixedPosition.nodeAfter ) ) {
  125. return new Range( fixedPosition, Position.createAfter( fixedPosition.nodeAfter ) );
  126. }
  127. return new Range( fixedPosition );
  128. }
  129. // Tries to fix an expanded range.
  130. //
  131. // @param {module:engine/model/range~Range} range Expanded range to fix.
  132. // @param {module:engine/model/schema~Schema} schema
  133. // @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid.
  134. function tryFixingNonCollapsedRage( range, schema ) {
  135. const start = range.start;
  136. const end = range.end;
  137. const isTextAllowedOnStart = schema.checkChild( start, '$text' );
  138. const isTextAllowedOnEnd = schema.checkChild( end, '$text' );
  139. const startLimitElement = schema.getLimitElement( start );
  140. const endLimitElement = schema.getLimitElement( end );
  141. // Ranges which both end are inside the same limit element (or root) might needs only minor fix.
  142. if ( startLimitElement === endLimitElement ) {
  143. // Range is valid when both position allows to place a text:
  144. // - <block>f[oobarba]z</block>
  145. // 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.
  146. if ( isTextAllowedOnStart && isTextAllowedOnEnd ) {
  147. return null;
  148. }
  149. // Range that is on non-limit element (or is partially) must be fixed so it is placed inside the block around $text:
  150. // - [<block>foo</block>] -> <block>[foo]</block>
  151. // - [<block>foo]</block> -> <block>[foo]</block>
  152. // - <block>f[oo</block>] -> <block>f[oo]</block>
  153. if ( checkSelectionOnNonLimitElements( start, end, schema ) ) {
  154. const fixedStart = schema.getNearestSelectionRange( start, 'forward' );
  155. const fixedEnd = schema.getNearestSelectionRange( end, 'backward' );
  156. return new Range( fixedStart ? fixedStart.start : start, fixedEnd ? fixedEnd.start : end );
  157. }
  158. }
  159. const isStartInLimit = startLimitElement && !startLimitElement.is( 'rootElement' );
  160. const isEndInLimit = endLimitElement && !endLimitElement.is( 'rootElement' );
  161. // At this point we eliminated valid positions on text nodes so if one of range positions is placed inside a limit element
  162. // then the range crossed limit element boundaries and needs to be fixed.
  163. if ( isStartInLimit || isEndInLimit ) {
  164. // Although we've already found limit element on start/end positions we must find the outer-most limit element.
  165. // as limit elements might be nested directly inside (ie table > tableRow > tableCell).
  166. const fixedStart = isStartInLimit ? expandSelectionOnIsLimitNode( Position.createAt( startLimitElement ), schema, 'start' ) : start;
  167. const fixedEnd = isEndInLimit ? expandSelectionOnIsLimitNode( Position.createAt( endLimitElement ), schema, 'end' ) : end;
  168. return new Range( fixedStart, fixedEnd );
  169. }
  170. // Range was not fixed at this point so it is valid - ie it was placed around limit element already.
  171. return null;
  172. }
  173. // Expands selection so it contains whole limit node.
  174. //
  175. // @param {module:engine/model/position~Position} position
  176. // @param {module:engine/model/schema~Schema} schema
  177. // @param {String} expandToDirection Direction of expansion - either 'start' or 'end' of the range.
  178. // @returns {module:engine/model/position~Position}
  179. function expandSelectionOnIsLimitNode( position, schema, expandToDirection ) {
  180. let node = position.parent;
  181. let parent = node;
  182. // Find outer most isLimit block as such blocks might be nested (ie. in tables).
  183. while ( schema.isLimit( parent ) && parent.parent ) {
  184. node = parent;
  185. parent = parent.parent;
  186. }
  187. // Depending on direction of expanding selection return position before or after found node.
  188. return expandToDirection === 'start' ? Position.createBefore( node ) : Position.createAfter( node );
  189. }
  190. // Returns minimal set of continuous ranges.
  191. //
  192. // @param {Array.<module:engine/model/range~Range>} ranges
  193. // @returns {Array.<module:engine/model/range~Range>}
  194. function combineOverlappingRanges( ranges ) {
  195. const combinedRanges = [];
  196. // Seed the state.
  197. let previousRange = ranges[ 0 ];
  198. combinedRanges.push( previousRange );
  199. // Go through each ranges and check if it can be merged with previous one.
  200. for ( const range of ranges ) {
  201. // Do not push same ranges (ie might be created in a table).
  202. if ( range.isEqual( previousRange ) ) {
  203. continue;
  204. }
  205. // Merge intersecting range into previous one.
  206. if ( range.isIntersecting( previousRange ) ) {
  207. const newStart = previousRange.start.isBefore( range.start ) ? previousRange.start : range.start;
  208. const newEnd = range.end.isAfter( previousRange.end ) ? range.end : previousRange.end;
  209. const combinedRange = new Range( newStart, newEnd );
  210. // Replace previous range with the combined one.
  211. combinedRanges.splice( combinedRanges.indexOf( previousRange ), 1, combinedRange );
  212. previousRange = combinedRange;
  213. continue;
  214. }
  215. previousRange = range;
  216. combinedRanges.push( range );
  217. }
  218. return combinedRanges;
  219. }
  220. // Checks whether both range ends are placed around non-limit elements.
  221. //
  222. // @param {module:engine/model/position~Position} start
  223. // @param {module:engine/model/position~Position} end
  224. // @param {module:engine/model/schema~Schema} schema
  225. function checkSelectionOnNonLimitElements( start, end, schema ) {
  226. const startIsOnBlock = ( start.nodeAfter && !schema.isLimit( start.nodeAfter ) ) || schema.checkChild( start, '$text' );
  227. const endIsOnBlock = ( end.nodeBefore && !schema.isLimit( end.nodeBefore ) ) || schema.checkChild( end, '$text' );
  228. return startIsOnBlock && endIsOnBlock;
  229. }