/** * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ /** * @module engine/mode/utils/selection-post-fixer */ import Range from '../range'; import Position from '../position'; /** * Injects selection post-fixer to the model. * * The role of the selection post-fixer is to ensure that the selection is in a correct place * after a {@link module:engine/model/model~Model#change `change()`} block was executed. * * The correct position means that: * * * All collapsed selection ranges are in a place where the {@link module:engine/model/schema~Schema} * allows a `$text`. * * None of the selection's non-collapsed ranges crosses a {@link module:engine/model/schema~Schema#isLimit limit element} * boundary (a range must be rooted within one limit element). * * Only {@link module:engine/model/schema~Schema#isObject object elements} can be selected from the outside * (e.g. `[foo]` is invalid). This rule applies independently to both selection ends, so this * selection is correct: `f[oo]`. * * If the position is not correct, the post-fixer will automatically correct it. * * ## Fixing a non-collapsed selection * * See as an example a selection that starts in a P1 element and ends inside the text of a TD element * (`[` and `]` are range boundaries and `(l)` denotes an element defined as `isLimit=true`): * * root * |- element P1 * | |- "foo" root * |- element TABLE (l) P1 TABLE P2 * | |- element TR (l) f o[o TR TR b a r * | | |- element TD (l) TD TD * | | |- "aaa" a]a a b b b * | |- element TR (l) * | | |- element TD (l) || * | | |- "bbb" || * |- element P2 VV * | |- "bar" * root * P1 TABLE] P2 * f o[o TR TR b a r * TD TD * a a a b b b * * In the example above, the TABLE, TR and TD are defined as `isLimit=true` in the schema. The range which is not contained within * 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. * 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 * expanded to select the whole TABLE element. * * **Note** If the selection contains multiple ranges, the method returns a minimal set of ranges that are not intersecting after expanding * them to select `isLimit=true` elements. * * @param {module:engine/model/model~Model} model */ export function injectSelectionPostFixer( model ) { model.document.registerPostFixer( writer => selectionPostFixer( writer, model ) ); } // The selection post-fixer. // // @param {module:engine/model/writer~Writer} writer // @param {module:engine/model/model~Model} model function selectionPostFixer( writer, model ) { const selection = model.document.selection; const schema = model.schema; const ranges = []; let wasFixed = false; for ( const modelRange of selection.getRanges() ) { // Go through all ranges in selection and try fixing each of them. // Those ranges might overlap but will be corrected later. const correctedRange = tryFixingRange( modelRange, schema ); if ( correctedRange ) { ranges.push( correctedRange ); wasFixed = true; } else { ranges.push( modelRange ); } } // If any of ranges were corrected update the selection. if ( wasFixed ) { // The above algorithm might create ranges that intersects each other when selection contains more then one range. // This is case happens mostly on Firefox which creates multiple ranges for selected table. const combinedRanges = combineOverlappingRanges( ranges ); writer.setSelection( combinedRanges, { backward: selection.isBackward } ); } } // Tries fixing a range if it's incorrect. // // @param {module:engine/model/range~Range} range // @param {module:engine/model/schema~Schema} schema // @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid. function tryFixingRange( range, schema ) { if ( range.isCollapsed ) { return tryFixingCollapsedRange( range, schema ); } return tryFixingNonCollapsedRage( range, schema ); } // Tries to fix collapsed ranges. // // * Fixes situation when a range is in a place where $text is not allowed // // @param {module:engine/model/range~Range} range Collapsed range to fix. // @param {module:engine/model/schema~Schema} schema // @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid. function tryFixingCollapsedRange( range, schema ) { const originalPosition = range.start; const nearestSelectionRange = schema.getNearestSelectionRange( originalPosition ); // This might be null ie when editor data is empty. // In such cases there is no need to fix the selection range. if ( !nearestSelectionRange ) { return null; } const fixedPosition = nearestSelectionRange.start; // Fixed position is the same as original - no need to return corrected range. if ( originalPosition.isEqual( fixedPosition ) ) { return null; } // Check single node selection (happens in tables). if ( fixedPosition.nodeAfter && schema.isLimit( fixedPosition.nodeAfter ) ) { return new Range( fixedPosition, Position.createAfter( fixedPosition.nodeAfter ) ); } return new Range( fixedPosition ); } // Tries to fix an expanded range. // // @param {module:engine/model/range~Range} range Expanded range to fix. // @param {module:engine/model/schema~Schema} schema // @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid. function tryFixingNonCollapsedRage( range, schema ) { const start = range.start; const end = range.end; const isTextAllowedOnStart = schema.checkChild( start, '$text' ); const isTextAllowedOnEnd = schema.checkChild( end, '$text' ); const startLimitElement = schema.getLimitElement( start ); const endLimitElement = schema.getLimitElement( end ); // Ranges which both end are inside the same limit element (or root) might needs only minor fix. if ( startLimitElement === endLimitElement ) { // Range is valid when both position allows to place a text: // - f[oobarba]z // 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. if ( isTextAllowedOnStart && isTextAllowedOnEnd ) { return null; } // Range that is on non-limit element (or is partially) must be fixed so it is placed inside the block around $text: // - [foo] -> [foo] // - [foo] -> [foo] // - f[oo] -> f[oo] if ( checkSelectionOnNonLimitElements( start, end, schema ) ) { const fixedStart = schema.getNearestSelectionRange( start, 'forward' ); const fixedEnd = schema.getNearestSelectionRange( end, 'backward' ); return new Range( fixedStart ? fixedStart.start : start, fixedEnd ? fixedEnd.start : end ); } } const isStartInLimit = startLimitElement && !startLimitElement.is( 'rootElement' ); const isEndInLimit = endLimitElement && !endLimitElement.is( 'rootElement' ); // At this point we eliminated valid positions on text nodes so if one of range positions is placed inside a limit element // then the range crossed limit element boundaries and needs to be fixed. if ( isStartInLimit || isEndInLimit ) { // Although we've already found limit element on start/end positions we must find the outer-most limit element. // as limit elements might be nested directly inside (ie table > tableRow > tableCell). const fixedStart = isStartInLimit ? expandSelectionOnIsLimitNode( Position.createAt( startLimitElement ), schema, 'start' ) : start; const fixedEnd = isEndInLimit ? expandSelectionOnIsLimitNode( Position.createAt( endLimitElement ), schema, 'end' ) : end; return new Range( fixedStart, fixedEnd ); } // Range was not fixed at this point so it is valid - ie it was placed around limit element already. return null; } // Expands selection so it contains whole limit node. // // @param {module:engine/model/position~Position} position // @param {module:engine/model/schema~Schema} schema // @param {String} expandToDirection Direction of expansion - either 'start' or 'end' of the range. // @returns {module:engine/model/position~Position} function expandSelectionOnIsLimitNode( position, schema, expandToDirection ) { let node = position.parent; let parent = node; // Find outer most isLimit block as such blocks might be nested (ie. in tables). while ( schema.isLimit( parent ) && parent.parent ) { node = parent; parent = parent.parent; } // Depending on direction of expanding selection return position before or after found node. return expandToDirection === 'start' ? Position.createBefore( node ) : Position.createAfter( node ); } // Returns minimal set of continuous ranges. // // @param {Array.} ranges // @returns {Array.} function combineOverlappingRanges( ranges ) { const combinedRanges = []; // Seed the state. let previousRange = ranges[ 0 ]; combinedRanges.push( previousRange ); // Go through each ranges and check if it can be merged with previous one. for ( const range of ranges ) { // Do not push same ranges (ie might be created in a table). if ( range.isEqual( previousRange ) ) { continue; } // Merge intersecting range into previous one. if ( range.isIntersecting( previousRange ) ) { const newStart = previousRange.start.isBefore( range.start ) ? previousRange.start : range.start; const newEnd = range.end.isAfter( previousRange.end ) ? range.end : previousRange.end; const combinedRange = new Range( newStart, newEnd ); // Replace previous range with the combined one. combinedRanges.splice( combinedRanges.indexOf( previousRange ), 1, combinedRange ); previousRange = combinedRange; continue; } previousRange = range; combinedRanges.push( range ); } return combinedRanges; } // Checks whether both range ends are placed around non-limit elements. // // @param {module:engine/model/position~Position} start // @param {module:engine/model/position~Position} end // @param {module:engine/model/schema~Schema} schema function checkSelectionOnNonLimitElements( start, end, schema ) { const startIsOnBlock = ( start.nodeAfter && !schema.isLimit( start.nodeAfter ) ) || schema.checkChild( start, '$text' ); const endIsOnBlock = ( end.nodeBefore && !schema.isLimit( end.nodeBefore ) ) || schema.checkChild( end, '$text' ); return startIsOnBlock && endIsOnBlock; }