| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- /**
- * @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. `[<paragraph>foo</paragraph>]` is invalid). This rule applies independently to both selection ends, so this
- * selection is correct: `<paragraph>f[oo</paragraph><image></image>]`.
- *
- * 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:
- // - <block>f[oobarba]z</block>
- // 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:
- // - [<block>foo</block>] -> <block>[foo]</block>
- // - [<block>foo]</block> -> <block>[foo]</block>
- // - <block>f[oo</block>] -> <block>f[oo]</block>
- 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.<module:engine/model/range~Range>} ranges
- // @returns {Array.<module:engine/model/range~Range>}
- 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;
- }
|