|
| 1 | +/** |
| 2 | + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. |
| 3 | + * For licensing, see LICENSE.md. |
| 4 | + */ |
| 5 | + |
| 6 | +/** |
| 7 | + * @module engine/mode/utils/selection-post-fixer |
| 8 | + */ |
| 9 | + |
| 10 | +import Range from '../range'; |
| 11 | +import Position from '../position'; |
| 12 | + |
| 13 | +/** |
| 14 | + * Injects selection post-fixer to the model. |
| 15 | + * |
| 16 | + * The role of the selection post-fixer is to ensure that the selection is in a correct place |
| 17 | + * after a {@link module:engine/model/model~Model#change `change()`} block was executed. |
| 18 | + * |
| 19 | + * The correct position means that: |
| 20 | + * |
| 21 | + * * All collapsed selection ranges are in a place where the {@link module:engine/model/schema~Schema} |
| 22 | + * allows a `$text`. |
| 23 | + * * None of selection's non-collapsed ranges crosses a {@link module:engine/model/schema~Schema#isLimit limit element} |
| 24 | + * boundary (a range must be rooted within one limit element). |
| 25 | + * * Only {@link module:engine/model/schema~Schema#isObject object elements} can be selected from outside |
| 26 | + * (e.g. `[<paragraph>foo</paragraph>]` is invalid). This rule applies independently to both selection ends, so this |
| 27 | + * selection is correct – `<paragraph>f[oo</paragraph><image></image>]`. |
| 28 | + * |
| 29 | + * If the position is not correct, the post-fixer will automatically correct it. |
| 30 | + * |
| 31 | + * ## Fixing a non-collapsed selection |
| 32 | + * |
| 33 | + * See as an example a selection that starts in a P1 element and ends inside a text of a TD element |
| 34 | + * (`[` and `]` are range boundaries and `(l)` denotes element defines as `isLimit=true`): |
| 35 | + * |
| 36 | + * root |
| 37 | + * |- element P1 |
| 38 | + * | |- "foo" root |
| 39 | + * |- element TABLE (l) P1 TABLE P2 |
| 40 | + * | |- element TR (l) f o[o TR TR b a r |
| 41 | + * | | |- element TD (l) TD TD |
| 42 | + * | | |- "aaa" a]a a b b b |
| 43 | + * | |- element TR (l) |
| 44 | + * | | |- element TD (l) || |
| 45 | + * | | |- "bbb" || |
| 46 | + * |- element P2 VV |
| 47 | + * | |- "bar" |
| 48 | + * root |
| 49 | + * P1 TABLE] P2 |
| 50 | + * f o[o TR TR b a r |
| 51 | + * TD TD |
| 52 | + * a a a b b b |
| 53 | + * |
| 54 | + * In the above example, the TABLE, TR and TD are defined as `isLimit=true` in the schema. The range which is not contained within |
| 55 | + * a single limit element must be expanded to select the outer most limit element. The range end is inside text node of TD element. |
| 56 | + * As TD element is a child of TR element and TABLE elements which both are defined as `isLimit=true` in schema the range must be expanded |
| 57 | + * to select whole TABLE element. |
| 58 | + * |
| 59 | + * **Note** If selection contains multiple ranges the method returns minimal set of ranges that are not intersecting after expanding them |
| 60 | + * to select `isLimit=true` elements. |
| 61 | + * |
| 62 | + * @param {module:engine/model/model~Model} model |
| 63 | + */ |
| 64 | +export function injectSelectionPostFixer( model ) { |
| 65 | + model.document.registerPostFixer( writer => selectionPostFixer( writer, model ) ); |
| 66 | +} |
| 67 | + |
| 68 | +// The selection post-fixer. |
| 69 | +// |
| 70 | +// @param {module:engine/model/writer~Writer} writer |
| 71 | +// @param {module:engine/model/model~Model} model |
| 72 | +function selectionPostFixer( writer, model ) { |
| 73 | + const selection = model.document.selection; |
| 74 | + const schema = model.schema; |
| 75 | + |
| 76 | + const ranges = []; |
| 77 | + |
| 78 | + let wasFixed = false; |
| 79 | + |
| 80 | + for ( const modelRange of selection.getRanges() ) { |
| 81 | + // Go through all ranges in selection and try fixing each of them. |
| 82 | + // Those ranges might overlap but will be corrected later. |
| 83 | + const correctedRange = tryFixingRange( modelRange, schema ); |
| 84 | + |
| 85 | + if ( correctedRange ) { |
| 86 | + ranges.push( correctedRange ); |
| 87 | + wasFixed = true; |
| 88 | + } else { |
| 89 | + ranges.push( modelRange ); |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + // If any of ranges were corrected update the selection. |
| 94 | + if ( wasFixed ) { |
| 95 | + // The above algorithm might create ranges that intersects each other when selection contains more then one range. |
| 96 | + // This is case happens mostly on Firefox which creates multiple ranges for selected table. |
| 97 | + const combinedRanges = combineOverlapingRanges( ranges ); |
| 98 | + |
| 99 | + writer.setSelection( combinedRanges, { backward: selection.isBackward } ); |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +// Tries fixing a range if it's incorrect. |
| 104 | +// |
| 105 | +// @param {module:engine/model/range~Range} range |
| 106 | +// @param {module:engine/model/schema~Schema} schema |
| 107 | +// @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid. |
| 108 | +function tryFixingRange( range, schema ) { |
| 109 | + if ( range.isCollapsed ) { |
| 110 | + return tryFixingCollapsedRange( range, schema ); |
| 111 | + } |
| 112 | + |
| 113 | + return tryFixingNonCollpasedRage( range, schema ); |
| 114 | +} |
| 115 | + |
| 116 | +// Tries to fix collapsed ranges. |
| 117 | +// |
| 118 | +// * Fixes situation when a range is in a place where $text is not allowed |
| 119 | +// |
| 120 | +// @param {module:engine/model/range~Range} range Collapsed range to fix. |
| 121 | +// @param {module:engine/model/schema~Schema} schema |
| 122 | +// @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid. |
| 123 | +function tryFixingCollapsedRange( range, schema ) { |
| 124 | + const originalPosition = range.start; |
| 125 | + |
| 126 | + const nearestSelectionRange = schema.getNearestSelectionRange( originalPosition ); |
| 127 | + |
| 128 | + // This might be null ie when editor data is empty. |
| 129 | + // In such cases there is no need to fix the selection range. |
| 130 | + if ( !nearestSelectionRange ) { |
| 131 | + return null; |
| 132 | + } |
| 133 | + |
| 134 | + const fixedPosition = nearestSelectionRange.start; |
| 135 | + |
| 136 | + // Fixed position is the same as original - no need to return corrected range. |
| 137 | + if ( originalPosition.isEqual( fixedPosition ) ) { |
| 138 | + return null; |
| 139 | + } |
| 140 | + |
| 141 | + // Check single node selection (happens in tables). |
| 142 | + if ( fixedPosition.nodeAfter && schema.isLimit( fixedPosition.nodeAfter ) ) { |
| 143 | + return new Range( fixedPosition, Position.createAfter( fixedPosition.nodeAfter ) ); |
| 144 | + } |
| 145 | + |
| 146 | + return new Range( fixedPosition ); |
| 147 | +} |
| 148 | + |
| 149 | +// Tries to fix a expanded range that overlaps limit nodes. |
| 150 | +// |
| 151 | +// @param {module:engine/model/range~Range} range Expanded range to fix. |
| 152 | +// @param {module:engine/model/schema~Schema} schema |
| 153 | +// @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid. |
| 154 | +function tryFixingNonCollpasedRage( range, schema ) { |
| 155 | + // No need to check flat ranges as they will not cross node boundary. |
| 156 | + if ( range.isFlat ) { |
| 157 | + return null; |
| 158 | + } |
| 159 | + |
| 160 | + const start = range.start; |
| 161 | + const end = range.end; |
| 162 | + |
| 163 | + const updatedStart = expandSelectionOnIsLimitNode( start, schema, 'start' ); |
| 164 | + const updatedEnd = expandSelectionOnIsLimitNode( end, schema, 'end' ); |
| 165 | + |
| 166 | + if ( !start.isEqual( updatedStart ) || !end.isEqual( updatedEnd ) ) { |
| 167 | + return new Range( updatedStart, updatedEnd ); |
| 168 | + } |
| 169 | + |
| 170 | + return null; |
| 171 | +} |
| 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 | + |
| 183 | + // Find outer most isLimit block as such blocks might be nested (ie. in tables). |
| 184 | + while ( schema.isLimit( parent ) && parent.parent ) { |
| 185 | + node = parent; |
| 186 | + parent = parent.parent; |
| 187 | + } |
| 188 | + |
| 189 | + if ( node === parent ) { |
| 190 | + // If there is not is limit block the return original position. |
| 191 | + return position; |
| 192 | + } |
| 193 | + |
| 194 | + // Depending on direction of expanding selection return position before or after found node. |
| 195 | + return expandToDirection === 'start' ? Position.createBefore( node ) : Position.createAfter( node ); |
| 196 | +} |
| 197 | + |
| 198 | +// Returns minimal set of continuous ranges. |
| 199 | +// |
| 200 | +// @param {Array.<module:engine/model/range~Range>} ranges |
| 201 | +// @returns {Array.<module:engine/model/range~Range>} |
| 202 | +function combineOverlapingRanges( ranges ) { |
| 203 | + const combinedRanges = []; |
| 204 | + |
| 205 | + // Seed the state. |
| 206 | + let previousRange = ranges[ 0 ]; |
| 207 | + combinedRanges.push( previousRange ); |
| 208 | + |
| 209 | + // Go through each ranges and check if it can be merged with previous one. |
| 210 | + for ( const range of ranges ) { |
| 211 | + // Do not push same ranges (ie might be created in a table). |
| 212 | + if ( range.isEqual( previousRange ) ) { |
| 213 | + continue; |
| 214 | + } |
| 215 | + |
| 216 | + // Merge intersecting range into previous one. |
| 217 | + if ( range.isIntersecting( previousRange ) ) { |
| 218 | + const newStart = previousRange.start.isBefore( range.start ) ? previousRange.start : range.start; |
| 219 | + const newEnd = range.end.isAfter( previousRange.end ) ? range.end : previousRange.end; |
| 220 | + const combinedRange = new Range( newStart, newEnd ); |
| 221 | + |
| 222 | + // Replace previous range with the combined one. |
| 223 | + combinedRanges.splice( combinedRanges.indexOf( previousRange ), 1, combinedRange ); |
| 224 | + |
| 225 | + previousRange = combinedRange; |
| 226 | + |
| 227 | + continue; |
| 228 | + } |
| 229 | + |
| 230 | + previousRange = range; |
| 231 | + combinedRanges.push( range ); |
| 232 | + } |
| 233 | + |
| 234 | + return combinedRanges; |
| 235 | +} |
0 commit comments