8
0

selection-post-fixer.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  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/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. writer.setSelection( mergeIntersectingRanges( ranges ), { backward: selection.isBackward } );
  87. }
  88. }
  89. // Tries fixing a range if it's incorrect.
  90. //
  91. // @param {module:engine/model/range~Range} range
  92. // @param {module:engine/model/schema~Schema} schema
  93. // @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid.
  94. function tryFixingRange( range, schema ) {
  95. if ( range.isCollapsed ) {
  96. return tryFixingCollapsedRange( range, schema );
  97. }
  98. return tryFixingNonCollapsedRage( range, schema );
  99. }
  100. // Tries to fix collapsed ranges.
  101. //
  102. // * Fixes situation when a range is in a place where $text is not allowed
  103. //
  104. // @param {module:engine/model/range~Range} range Collapsed range to fix.
  105. // @param {module:engine/model/schema~Schema} schema
  106. // @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid.
  107. function tryFixingCollapsedRange( range, schema ) {
  108. const originalPosition = range.start;
  109. const nearestSelectionRange = schema.getNearestSelectionRange( originalPosition );
  110. // This might be null ie when editor data is empty.
  111. // In such cases there is no need to fix the selection range.
  112. if ( !nearestSelectionRange ) {
  113. return null;
  114. }
  115. if ( !nearestSelectionRange.isCollapsed ) {
  116. return nearestSelectionRange;
  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. return new Range( fixedPosition );
  124. }
  125. // Tries to fix an expanded range.
  126. //
  127. // @param {module:engine/model/range~Range} range Expanded range to fix.
  128. // @param {module:engine/model/schema~Schema} schema
  129. // @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid.
  130. function tryFixingNonCollapsedRage( range, schema ) {
  131. const start = range.start;
  132. const end = range.end;
  133. const isTextAllowedOnStart = schema.checkChild( start, '$text' );
  134. const isTextAllowedOnEnd = schema.checkChild( end, '$text' );
  135. const startLimitElement = schema.getLimitElement( start );
  136. const endLimitElement = schema.getLimitElement( end );
  137. // Ranges which both end are inside the same limit element (or root) might needs only minor fix.
  138. if ( startLimitElement === endLimitElement ) {
  139. // Range is valid when both position allows to place a text:
  140. // - <block>f[oobarba]z</block>
  141. // 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.
  142. if ( isTextAllowedOnStart && isTextAllowedOnEnd ) {
  143. return null;
  144. }
  145. // Range that is on non-limit element (or is partially) must be fixed so it is placed inside the block around $text:
  146. // - [<block>foo</block>] -> <block>[foo]</block>
  147. // - [<block>foo]</block> -> <block>[foo]</block>
  148. // - <block>f[oo</block>] -> <block>f[oo]</block>
  149. // - [<block>foo</block><object></object>] -> <block>[foo</block><object></object>]
  150. if ( checkSelectionOnNonLimitElements( start, end, schema ) ) {
  151. const isStartObject = start.nodeAfter && schema.isObject( start.nodeAfter );
  152. const fixedStart = isStartObject ? null : schema.getNearestSelectionRange( start, 'forward' );
  153. const isEndObject = end.nodeBefore && schema.isObject( end.nodeBefore );
  154. const fixedEnd = isEndObject ? null : schema.getNearestSelectionRange( end, 'backward' );
  155. // The schema.getNearestSelectionRange might return null - if that happens use original position.
  156. const rangeStart = fixedStart ? fixedStart.start : start;
  157. const rangeEnd = fixedEnd ? fixedEnd.start : end;
  158. return new Range( rangeStart, rangeEnd );
  159. }
  160. }
  161. const isStartInLimit = startLimitElement && !startLimitElement.is( 'rootElement' );
  162. const isEndInLimit = endLimitElement && !endLimitElement.is( 'rootElement' );
  163. // At this point we eliminated valid positions on text nodes so if one of range positions is placed inside a limit element
  164. // then the range crossed limit element boundaries and needs to be fixed.
  165. if ( isStartInLimit || isEndInLimit ) {
  166. const bothInSameParent = ( start.nodeAfter && end.nodeBefore ) && start.nodeAfter.parent === end.nodeBefore.parent;
  167. const expandStart = isStartInLimit && ( !bothInSameParent || !isInObject( start.nodeAfter, schema ) );
  168. const expandEnd = isEndInLimit && ( !bothInSameParent || !isInObject( end.nodeBefore, schema ) );
  169. // Although we've already found limit element on start/end positions we must find the outer-most limit element.
  170. // as limit elements might be nested directly inside (ie table > tableRow > tableCell).
  171. let fixedStart = start;
  172. let fixedEnd = end;
  173. if ( expandStart ) {
  174. fixedStart = Position._createBefore( findOutermostLimitAncestor( startLimitElement, schema ) );
  175. }
  176. if ( expandEnd ) {
  177. fixedEnd = Position._createAfter( findOutermostLimitAncestor( endLimitElement, schema ) );
  178. }
  179. return new Range( fixedStart, fixedEnd );
  180. }
  181. // Range was not fixed at this point so it is valid - ie it was placed around limit element already.
  182. return null;
  183. }
  184. // Finds the outer-most ancestor.
  185. //
  186. // @param {module:engine/model/node~Node} startingNode
  187. // @param {module:engine/model/schema~Schema} schema
  188. // @param {String} expandToDirection Direction of expansion - either 'start' or 'end' of the range.
  189. // @returns {module:engine/model/node~Node}
  190. function findOutermostLimitAncestor( startingNode, schema ) {
  191. let isLimitNode = startingNode;
  192. let parent = isLimitNode;
  193. // Find outer most isLimit block as such blocks might be nested (ie. in tables).
  194. while ( schema.isLimit( parent ) && parent.parent ) {
  195. isLimitNode = parent;
  196. parent = parent.parent;
  197. }
  198. return isLimitNode;
  199. }
  200. // Checks whether any of range boundaries is placed around non-limit elements.
  201. //
  202. // @param {module:engine/model/position~Position} start
  203. // @param {module:engine/model/position~Position} end
  204. // @param {module:engine/model/schema~Schema} schema
  205. // @returns {Boolean}
  206. function checkSelectionOnNonLimitElements( start, end, schema ) {
  207. const startIsOnBlock = ( start.nodeAfter && !schema.isLimit( start.nodeAfter ) ) || schema.checkChild( start, '$text' );
  208. const endIsOnBlock = ( end.nodeBefore && !schema.isLimit( end.nodeBefore ) ) || schema.checkChild( end, '$text' );
  209. // We should fix such selection when one of those nodes needs fixing.
  210. return startIsOnBlock || endIsOnBlock;
  211. }
  212. // Returns a minimal non-intersecting array of ranges.
  213. //
  214. // @param {Array.<module:engine/model/range~Range>} ranges
  215. // @returns {Array.<module:engine/model/range~Range>}
  216. function mergeIntersectingRanges( ranges ) {
  217. const nonIntersectingRanges = [];
  218. // First range will always be fine.
  219. nonIntersectingRanges.push( ranges.shift() );
  220. for ( const range of ranges ) {
  221. const previousRange = nonIntersectingRanges.pop();
  222. if ( range.isIntersecting( previousRange ) ) {
  223. // Get the sum of two ranges.
  224. const start = previousRange.start.isAfter( range.start ) ? range.start : previousRange.start;
  225. const end = previousRange.end.isAfter( range.end ) ? previousRange.end : range.end;
  226. const merged = new Range( start, end );
  227. nonIntersectingRanges.push( merged );
  228. } else {
  229. nonIntersectingRanges.push( previousRange );
  230. nonIntersectingRanges.push( range );
  231. }
  232. }
  233. return nonIntersectingRanges;
  234. }
  235. // Checks if node exists and if it's an object.
  236. //
  237. // @param {module:engine/model/node~Node} node
  238. // @param {module:engine/model/schema~Schema} schema
  239. // @returns {Boolean}
  240. function isInObject( node, schema ) {
  241. return node && schema.isObject( node );
  242. }