diff --git a/packages/ckeditor5-engine/src/model/operation/transform.ts b/packages/ckeditor5-engine/src/model/operation/transform.ts index e5c4da8ca06..a7cdcca1f2b 100644 --- a/packages/ckeditor5-engine/src/model/operation/transform.ts +++ b/packages/ckeditor5-engine/src/model/operation/transform.ts @@ -373,6 +373,9 @@ export function transformSets( operationsB.splice( indexB, 1, ...newOpsB ); } + handlePartialMarkerOperations( operationsA ); + handlePartialMarkerOperations( operationsB ); + if ( options.padWithNoOps ) { // If no-operations padding is enabled, count how many extra `a` and `b` operations were generated. const brokenOperationsACount = operationsA.length - data.originalOperationsACount; @@ -524,6 +527,9 @@ class ContextFactory { const range = Range._createFromPositionAndShift( opB.sourcePosition, opB.howMany ); if ( opA.splitPosition.hasSameParentAs( opB.sourcePosition ) && range.containsPosition( opA.splitPosition ) ) { + // TODO: Potential bug -- we are saving offset value directly and it is not later updated during OT. + // TODO: This may cause a bug it here was an non-undone operation that may have impacted this offset. + // TODO: Similar error was with MarkerOperation relations, where full path was saved and never updated. const howMany = range.end.offset - opA.splitPosition.offset; const offset = opA.splitPosition.offset - range.start.offset; @@ -564,22 +570,7 @@ class ContextFactory { return; } - if ( opB instanceof MoveOperation ) { - const movedRange = Range._createFromPositionAndShift( opB.sourcePosition, opB.howMany ); - - const affectedLeft = movedRange.containsPosition( markerRange.start ) || - movedRange.start.isEqual( markerRange.start ); - - const affectedRight = movedRange.containsPosition( markerRange.end ) || - movedRange.end.isEqual( markerRange.end ); - - if ( ( affectedLeft || affectedRight ) && !movedRange.containsRange( markerRange ) ) { - this._setRelation( opA, opB, { - side: affectedLeft ? 'left' : 'right', - path: affectedLeft ? markerRange.start.path.slice() : markerRange.end.path.slice() - } ); - } - } else if ( opB instanceof MergeOperation ) { + if ( opB instanceof MergeOperation ) { const wasInLeftElement = markerRange.start.isEqual( opB.targetPosition ); const wasStartBeforeMergedElement = markerRange.start.isEqual( opB.deletionPosition ); const wasEndBeforeMergedElement = markerRange.end.isEqual( opB.deletionPosition ); @@ -748,6 +739,61 @@ function padWithNoOps( operations: Array, howMany: number ) { } } +/** + * Transformed operations set may include marker operations which were broken into multiple marker operations during transformation. + * It represents marker range being broken into multiple pieces as the transformation was processed. Each partial marker operation is + * a piece of the original marker range. + * + * These partial marker operations ("marker range pieces") should be "glued" together if, after transformations, the ranges ended up + * next to each other. + * + * If the ranges did not end up next to each other, then partial marker operations should be discarded, as the marker range cannot + * be broken into two pieces. + * + * There is always one "reference" marker operation (the original operation) and there may be some partial marker operations. Partial + * marker operations have base version set to `-1`. If the `operations` set includes partial marker operations, then they are always + * after the original marker operation. + * + * See also `MarkerOperation` x `MoveOperation` transformation. + * See also https://github.com/ckeditor/ckeditor5/pull/17071. + */ +function handlePartialMarkerOperations( operations: Array ) { + const markerOps: Map }> = new Map(); + + for ( let i = 0; i < operations.length; i++ ) { + const op = operations[ i ]; + + if ( !( op instanceof MarkerOperation ) ) { + continue; + } + + if ( op.baseVersion !== -1 ) { + markerOps.set( op.name, { + op, + ranges: op.newRange ? [ op.newRange ] : [] + } ); + } else { + if ( op.newRange ) { + // `markerOps.get( op.name )` must exist because original marker operation is always before partial marker operations. + // If the original marker operation was changed to `NoOperation`, then the partial marker operations would be changed + // to `NoOperation` as well, so this is not a case. + markerOps.get( op.name )!.ranges.push( op.newRange ); + } + + operations.splice( i, 1 ); + i--; + } + } + + for ( const { op, ranges } of markerOps.values() ) { + if ( ranges.length ) { + op.newRange = Range._createFromRanges( ranges ); + } else { + op.newRange = null; + } + } +} + // ----------------------- setTransformation( AttributeOperation, AttributeOperation, ( a, b, context ) => { @@ -1153,32 +1199,52 @@ setTransformation( MarkerOperation, MergeOperation, ( a, b ) => { return [ a ]; } ); -setTransformation( MarkerOperation, MoveOperation, ( a, b, context ) => { +setTransformation( MarkerOperation, MoveOperation, ( a, b ) => { + const result = [ a ]; + if ( a.oldRange ) { a.oldRange = Range._createFromRanges( a.oldRange._getTransformedByMoveOperation( b ) ); } if ( a.newRange ) { - if ( context.abRelation ) { - const aNewRange = Range._createFromRanges( a.newRange._getTransformedByMoveOperation( b ) ); + // In many simple cases the marker range will be kept integral after the transformation. For example, if some nodes + // were inserted before the range, or into the range, then the marker range is not broken into two. + // + // However, if some nodes are taken out of the range and moved somewhere else, or are moved into the range, then the marker + // range is "broken" into two or three pieces, and these pieces must be transformed and updated separately. + // + // When the marker range is transformed by move operation, as a result we get an array with one (simple case) or multiple + // ("broken range" case) ranges. + const ranges = a.newRange._getTransformedByMoveOperation( b ); - if ( context.abRelation.side == 'left' && b.targetPosition.isEqual( a.newRange.start ) ) { - ( a.newRange as any ).end = aNewRange.end; - ( a.newRange.start as any ).path = context.abRelation.path; + a.newRange = ranges[ 0 ]; - return [ a ]; - } else if ( context.abRelation.side == 'right' && b.targetPosition.isEqual( a.newRange.end ) ) { - ( a.newRange as any ).start = aNewRange.start; - ( a.newRange.end as any ).path = context.abRelation.path; + // If there are multiple ranges, we will create separate marker operations for each piece of the original marker range. + // Since they will be marker operations, they will be processed through the transformation process. + // + // However, we cannot create multiple ranges for the same marker (for the same marker name). A marker has only one range. + // So, we cannot really have multiple marker operations for the same marker. We will keep the track of the separate marker + // operations to see, if after all transformations, the marker pieces are next to each other or not. If so, we will glue + // them together to the original marker operation (`a`). If not, we will discard them. These extra operations will never + // be executed, as they will only exist temporarily during the transformation process. + // + // We will call these additional marker operations "partial marker operations" and we will mark them with negative base version. + // + // See also `handlePartialMarkerOperations()`. + // See also https://github.com/ckeditor/ckeditor5/pull/17071. + // + for ( let i = 1; i < ranges.length; i++ ) { + const op = a.clone(); - return [ a ]; - } - } + op.oldRange = null; + op.newRange = ranges[ i ]; + op.baseVersion = -1; - a.newRange = Range._createFromRanges( a.newRange._getTransformedByMoveOperation( b ) ); + result.push( op ); + } } - return [ a ]; + return result; } ); setTransformation( MarkerOperation, SplitOperation, ( a, b, context ) => { @@ -1194,6 +1260,8 @@ setTransformation( MarkerOperation, SplitOperation, ( a, b, context ) => { ( a.newRange as any ).start = Position._createAt( b.insertionPosition ); } else if ( a.newRange.start.isEqual( b.splitPosition ) && !context.abRelation.wasInLeftElement ) { ( a.newRange as any ).start = Position._createAt( b.moveTargetPosition ); + } else { + ( a.newRange as any ).start = aNewRange.start; } if ( a.newRange.end.isEqual( b.splitPosition ) && context.abRelation.wasInRightElement ) { @@ -1313,6 +1381,7 @@ setTransformation( MergeOperation, MergeOperation, ( a, b, context ) => { } // The default case. + // TODO: Possibly, there's a missing case for same `targetPosition` but different `sourcePosition`. // if ( a.sourcePosition.hasSameParentAs( b.targetPosition ) ) { a.howMany += b.howMany; @@ -1340,11 +1409,11 @@ setTransformation( MergeOperation, MoveOperation, ( a, b, context ) => { // was to have it all deleted, together with its children. From user experience point of view, moving back the // removed nodes might be unexpected. This means that in this scenario we will block the merging. // - // The exception of this rule would be if the remove operation was later undone. + // The exception to this rule would be if the remove operation was later undone. // const removedRange = Range._createFromPositionAndShift( b.sourcePosition, b.howMany ); - if ( b.type == 'remove' && !context.bWasUndone && !context.forceWeakRemove ) { + if ( b.type == 'remove' && !context.bWasUndone ) { if ( a.deletionPosition.hasSameParentAs( b.sourcePosition ) && removedRange.containsPosition( a.sourcePosition ) ) { return [ new NoOperation( 0 ) ]; } @@ -1455,60 +1524,67 @@ setTransformation( MergeOperation, SplitOperation, ( a, b, context ) => { // Case 1: // // Merge operation moves nodes to the place where split happens. - // This is a classic situation when there are two paragraphs, and there is a split (enter) after the first + // + // This is a classic situation when there are two paragraphs, and there is a split (enter) at the end of the first // paragraph and there is a merge (delete) at the beginning of the second paragraph: // //

Foo{}

[]Bar

. // - // Split is after `Foo`, while merge is from `Bar` to the end of `Foo`. + // User A presses enter after `Foo`, while User B presses backspace before `Bar`. It is intuitive that after both operations, the + // editor state should stay the same. // // State after split: - //

Foo

Bar

+ //

Foo

[]Bar

// - // Now, `Bar` should be merged to the new paragraph: + // When this happens, `Bar` should be merged to the newly created paragraph, to maintain the editor state: //

Foo

Bar

// - // Instead of merging it to the original paragraph: + // Another option is to merge into the original paragraph `Foo`, according to the `targetPosition`. This results in an incorrect state: //

FooBar

// - // This means that `targetPosition` needs to be transformed. This is the default case though. - // For example, if the split would be after `F`, `targetPosition` should also be transformed. + // Also, consider an example where User A also writes something in the new paragraph: + //

Foo

Xyz

[]Bar

+ // + // In this case it is clear that merge should happen into `[ 1, 3 ]` not into `[ 0, 3 ]`. It first has to be transformed to `[ 1, 0 ]`, + // and then transformed be insertion into `[ 1, 3 ]`. // - // There are three exceptions, though, when we want to keep `targetPosition` as it was. + // So, usually we want to move `targetPosition` to the new paragraph when it is same as split position. This is how it is handled + // in the default transformation (`_getTransformedBySplitOperation()`). We don't need a special case for this. // - // First exception is when the merge target position is inside an element (not at the end, as usual). This - // happens when the merge operation earlier was transformed by "the same" merge operation. If merge operation - // targets inside the element we want to keep the original target position (and not transform it) because - // we have additional context telling us that we want to merge to the original element. We can check if the - // merge operation points inside element by checking what is `SplitOperation#howMany`. Since merge target position - // is same as split position, if `howMany` is non-zero, it means that the merge target position is inside an element. + // However, there are two exceptions, when we **do not** want to transform `targetPosition`, and we need a special case then. // - // Second exception is when the element to merge is in the graveyard and split operation uses it. In that case + // These exceptions happen only if undo is involved. During OT, above presented case (`

Foo{}

[]Bar

`) is the only way + // how `SplitOperation#splitPosition` and `MergeOperation#targetPosition` can be the same. + // + // First exception is when the element to merge is in the graveyard and split operation uses it. In that case // if target position would be transformed, the merge operation would target at the source position: // - // root:

Foo

graveyard:

+ // root:

Foo[]

graveyard:

// // SplitOperation: root [ 0, 3 ] using graveyard [ 0 ] (howMany = 0) // MergeOperation: graveyard [ 0, 0 ] -> root [ 0, 3 ] (howMany = 0) // - // Since split operation moves the graveyard node back to the root, the merge operation source position changes. - // We would like to merge from the empty

to the "Foo"

: - // - // root:

Foo

graveyard: + // Since split operation moves the graveyard element back to the root (to path `[ 1 ]`), the merge operation `sourcePosition` changes. + // After split we have: `

Foo

`, so `sourcePosition` is `[ 1, 0 ]`. But if `targetPosition` is transformed, then it + // also becomes `[ 1, 0 ]`. In this case, we want to keep the `targetPosition` as it was. // - // MergeOperation#sourcePosition = root [ 1, 0 ] + // Second exception is connected strictly with undo relations. If this `MergeOperation` was earlier transformed by + // `MergeOperation` and we stored an information that earlier the target position was not affected, then here, when transforming by + // `SplitOperation` we are not going to change it as well. // - // If `targetPosition` is transformed, it would become root [ 1, 0 ] as well. It has to be kept as it was. + // For these two cases we will only transform `sourcePosition` and return early. // - // Third exception is connected with relations. If this happens during undo and we have explicit information - // that target position has not been affected by the operation which is undone by this split then this split should - // not move the target position either. + // Note, that earlier there was also third special case here. `targetPosition` was not transformed, if it pointed into the middle of + // target element, not into its end (as usual). This can also happen only with undo involved. However, it wasn't always a correct + // solution, as in some cases we actually wanted to transform `targetPosition`. Also, this case usually happens together with the second + // case described above. There is only one scenario that we have in our unit tests, where this third case happened without second case. + // However, this scenario went fine no matter if we transformed `targetPosition` or not. That's because this happened in the middle + // of transformation process and the operation was correctly transformed later on. // if ( a.targetPosition.isEqual( b.splitPosition ) ) { - const mergeInside = b.howMany != 0; const mergeSplittingElement = b.graveyardPosition && a.deletionPosition.isEqual( b.graveyardPosition ); - if ( mergeInside || mergeSplittingElement || context.abRelation == 'mergeTargetNotMoved' ) { + if ( mergeSplittingElement || context.abRelation == 'mergeTargetNotMoved' ) { a.sourcePosition = a.sourcePosition._getTransformedBySplitOperation( b ); return [ a ]; @@ -1899,14 +1975,17 @@ setTransformation( MoveOperation, MergeOperation, ( a, b, context ) => { let gyMoveSource = b.graveyardPosition.clone(); let splitNodesMoveSource = b.targetPosition._getTransformedByMergeOperation( b ); + // `a.targetPosition` points to graveyard, so it was probably affected by `b` (which moved merged element to the graveyard). + const aTarget = a.targetPosition.getTransformedByOperation( b ); + if ( a.howMany > 1 ) { - results.push( new MoveOperation( a.sourcePosition, a.howMany - 1, a.targetPosition, 0 ) ); + results.push( new MoveOperation( a.sourcePosition, a.howMany - 1, aTarget, 0 ) ); - gyMoveSource = gyMoveSource._getTransformedByMove( a.sourcePosition, a.targetPosition, a.howMany - 1 ); - splitNodesMoveSource = splitNodesMoveSource._getTransformedByMove( a.sourcePosition, a.targetPosition, a.howMany - 1 ); + gyMoveSource = gyMoveSource._getTransformedByMove( a.sourcePosition, aTarget, a.howMany - 1 ); + splitNodesMoveSource = splitNodesMoveSource._getTransformedByMove( a.sourcePosition, aTarget, a.howMany - 1 ); } - const gyMoveTarget = b.deletionPosition._getCombined( a.sourcePosition, a.targetPosition ); + const gyMoveTarget = b.deletionPosition._getCombined( a.sourcePosition, aTarget ); const gyMove = new MoveOperation( gyMoveSource, 1, gyMoveTarget, 0 ); const splitNodesMoveTargetPath = gyMove.getMovedRangeStart().path.slice(); diff --git a/packages/ckeditor5-engine/src/model/range.ts b/packages/ckeditor5-engine/src/model/range.ts index d18f81eef15..9e069499934 100644 --- a/packages/ckeditor5-engine/src/model/range.ts +++ b/packages/ckeditor5-engine/src/model/range.ts @@ -966,7 +966,7 @@ export default class Range extends TypeCheckable implements Iterable { return a.start.isAfter( b.start ) ? 1 : -1; } ); @@ -975,21 +975,18 @@ export default class Range extends TypeCheckable implements Iterable 0 ) { - // eslint-disable-next-line no-constant-condition - for ( let i = refIndex - 1; true; i++ ) { - if ( ranges[ i ].end.isEqual( result.start ) ) { - ( result as any ).start = Position._createAt( ranges[ i ].start ); - } else { - // If ranges are not starting/ending at the same position there is no point in looking further. - break; - } + for ( let i = refIndex - 1; i >= 0; i-- ) { + if ( ranges[ i ].end.isEqual( result.start ) ) { + ( result as any ).start = Position._createAt( ranges[ i ].start ); + } else { + // If ranges are not starting/ending at the same position there is no point in looking further. + break; } } diff --git a/packages/ckeditor5-engine/tests/model/operation/transform/undo.js b/packages/ckeditor5-engine/tests/model/operation/transform/undo.js index f285aa90c24..07375b66ec2 100644 --- a/packages/ckeditor5-engine/tests/model/operation/transform/undo.js +++ b/packages/ckeditor5-engine/tests/model/operation/transform/undo.js @@ -3,779 +3,932 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -import { Client, expectClients, clearBuffer } from './utils.js'; +import { Client, expectClients, syncClients, clearBuffer } from './utils.js'; import DocumentFragment from '../../../../src/model/documentfragment.js'; import Element from '../../../../src/model/element.js'; import Text from '../../../../src/model/text.js'; describe( 'transform', () => { - let john; + describe( 'undo', () => { + let john; - beforeEach( () => { - return Client.get( 'john' ).then( client => ( john = client ) ); - } ); + beforeEach( () => { + return Client.get( 'john' ).then( client => ( john = client ) ); + } ); - afterEach( () => { - clearBuffer(); + afterEach( () => { + clearBuffer(); - return john.destroy(); - } ); + return john.destroy(); + } ); - it( 'split, remove', () => { - john.setData( 'Foo[]Bar' ); + it( 'split, remove', () => { + john.setData( 'Foo[]Bar' ); - john.split(); - john.setSelection( [ 1 ], [ 2 ] ); - john.remove(); - john.undo(); - john.undo(); + john.split(); + john.setSelection( [ 1 ], [ 2 ] ); + john.remove(); + john.undo(); + john.undo(); - expectClients( 'FooBar' ); - } ); + expectClients( 'FooBar' ); + } ); - it( 'move, merge', () => { - john.setData( '[Foo]Bar' ); + it( 'move, merge', () => { + john.setData( '[Foo]Bar' ); - john.move( [ 2 ] ); - john.setSelection( [ 1 ] ); - john.merge(); - john.undo(); - john.undo(); + john.move( [ 2 ] ); + john.setSelection( [ 1 ] ); + john.merge(); + john.undo(); + john.undo(); - expectClients( 'FooBar' ); - } ); + expectClients( 'FooBar' ); + } ); - it.skip( 'move multiple, merge', () => { - john.setData( '[FooBar]Xyz' ); + it.skip( 'move multiple, merge', () => { + john.setData( '[FooBar]Xyz' ); - john.move( [ 3 ] ); + john.move( [ 3 ] ); - expectClients( 'XyzFooBar' ); + expectClients( 'XyzFooBar' ); - john.setSelection( [ 1 ] ); - john.merge(); + john.setSelection( [ 1 ] ); + john.merge(); - expectClients( 'XyzFooBar' ); + expectClients( 'XyzFooBar' ); - john.undo(); + john.undo(); - expectClients( 'XyzFooBar' ); + expectClients( 'XyzFooBar' ); - john.undo(); + john.undo(); - // Wrong move is done. - expectClients( 'FooBarXyz' ); - } ); + // Wrong move is done. + expectClients( 'FooBarXyz' ); + } ); - it( 'move inside unwrapped content', () => { - john.setData( '
[Foo]Bar
' ); - - john.move( [ 0, 2 ] ); - john.setSelection( [ 0, 0 ] ); - john.unwrap(); - john.undo(); - john.undo(); - - expectClients( - '
' + - 'Foo' + - 'Bar' + - '
' - ); - } ); + it( 'move inside unwrapped content', () => { + john.setData( '
[Foo]Bar
' ); - it( 'remove node, merge', () => { - john.setData( 'Foo[Bar]' ); + john.move( [ 0, 2 ] ); + john.setSelection( [ 0, 0 ] ); + john.unwrap(); + john.undo(); + john.undo(); - john.remove(); - john.setSelection( [ 1 ] ); - john.merge(); - john.undo(); - john.undo(); + expectClients( + '
' + + 'Foo' + + 'Bar' + + '
' + ); + } ); - expectClients( 'FooBar' ); - } ); + it( 'remove node, merge', () => { + john.setData( 'Foo[Bar]' ); - it( 'merge, merge #1', () => { - john.setData( - '
' + - 'Foo' + - 'Bar' + - '
' + - '[]' + - '
' + - 'Xyz' + - '
' - ); - - john.merge(); - john.setSelection( [ 0, 2 ] ); - john.merge(); - - expectClients( - '
' + - 'Foo' + - 'BarXyz' + - '
' - ); - - john.undo(); - john.undo(); - - expectClients( - '
' + - 'Foo' + - 'Bar' + - '
' + - '
' + - 'Xyz' + - '
' - ); - } ); + john.remove(); + john.setSelection( [ 1 ] ); + john.merge(); + john.undo(); + john.undo(); - it( 'merge, merge #2', () => { - john.setData( - '
' + - 'Foo' + - '
' + - '[]' + - '
' + - 'Bar' + - 'Xyz' + - '
' - ); - - john.merge(); - john.setSelection( [ 0, 1 ] ); - john.merge(); - - expectClients( - '
' + - 'FooBar' + - 'Xyz' + - '
' - ); - - john.undo(); - john.undo(); - - expectClients( - '
' + - 'Foo' + - '
' + - '
' + - 'Bar' + - 'Xyz' + - '
' - ); - } ); + expectClients( 'FooBar' ); + } ); + + it( 'merge, merge #1', () => { + john.setData( + '
' + + 'Foo' + + 'Bar' + + '
' + + '[]' + + '
' + + 'Xyz' + + '
' + ); - it( 'merge, unwrap', () => { - john.setData( '[]Foo' ); + john.merge(); + john.setSelection( [ 0, 2 ] ); + john.merge(); - john.merge(); - john.setSelection( [ 0, 0 ] ); - john.unwrap(); + expectClients( + '
' + + 'Foo' + + 'BarXyz' + + '
' + ); - john.undo(); - john.undo(); + john.undo(); + john.undo(); + + expectClients( + '
' + + 'Foo' + + 'Bar' + + '
' + + '
' + + 'Xyz' + + '
' + ); + } ); - expectClients( 'Foo' ); - } ); + it( 'merge, merge #2', () => { + john.setData( + '
' + + 'Foo' + + '
' + + '[]' + + '
' + + 'Bar' + + 'Xyz' + + '
' + ); - it( 'remove node at the split position #1', () => { - john.setData( 'Ab[]Xy' ); + john.merge(); + john.setSelection( [ 0, 1 ] ); + john.merge(); - john.merge(); - john.setSelection( [ 0, 1 ], [ 0, 2 ] ); - john.remove(); + expectClients( + '
' + + 'FooBar' + + 'Xyz' + + '
' + ); - john.undo(); - john.undo(); + john.undo(); + john.undo(); + + expectClients( + '
' + + 'Foo' + + '
' + + '
' + + 'Bar' + + 'Xyz' + + '
' + ); + } ); - expectClients( 'AbXy' ); - } ); + it( 'merge, unwrap', () => { + john.setData( '[]Foo' ); - it( 'remove node at the split position #2', () => { - john.setData( 'Ab[]Xy' ); + john.merge(); + john.setSelection( [ 0, 0 ] ); + john.unwrap(); - john.merge(); - john.setSelection( [ 0, 2 ], [ 0, 3 ] ); - john.remove(); + john.undo(); + john.undo(); - john.undo(); - john.undo(); + expectClients( 'Foo' ); + } ); - expectClients( 'AbXy' ); - } ); + it( 'remove node at the split position #1', () => { + john.setData( 'Ab[]Xy' ); - it( 'undoing split after the element created by split has been removed', () => { - // This example is ported here from ckeditor5-undo to keep 100% CC in ckeditor5-engine alone. - john.setData( 'Foo[]bar' ); + john.merge(); + john.setSelection( [ 0, 1 ], [ 0, 2 ] ); + john.remove(); - john.split(); - john.setSelection( [ 0, 3 ], [ 1, 3 ] ); - john.delete(); + john.undo(); + john.undo(); - expectClients( 'Foo' ); + expectClients( 'AbXy' ); + } ); - john.undo(); + it( 'remove node at the split position #2', () => { + john.setData( 'Ab[]Xy' ); - expectClients( 'Foobar' ); + john.merge(); + john.setSelection( [ 0, 2 ], [ 0, 3 ] ); + john.remove(); - john.undo(); + john.undo(); + john.undo(); - expectClients( 'Foobar' ); - } ); + expectClients( 'AbXy' ); + } ); - it( 'remove text from paragraph and merge it', () => { - john.setData( 'Foo[Bar]' ); + it( 'undoing split after the element created by split has been removed', () => { + // This example is ported here from ckeditor5-undo to keep 100% CC in ckeditor5-engine alone. + john.setData( 'Foo[]bar' ); - john.remove(); - john.setSelection( [ 1 ] ); - john.merge(); + john.split(); + john.setSelection( [ 0, 3 ], [ 1, 3 ] ); + john.delete(); - expectClients( 'Foo' ); + expectClients( 'Foo' ); - john.undo(); + john.undo(); - expectClients( 'Foo' ); + expectClients( 'Foobar' ); - john.undo(); + john.undo(); - expectClients( 'FooBar' ); - } ); + expectClients( 'Foobar' ); + } ); - it( 'delete split paragraphs', () => { - john.setData( 'FooB[]ar' ); + it( 'remove text from paragraph and merge it', () => { + john.setData( 'Foo[Bar]' ); - john.split(); - john.setSelection( [ 2, 1 ] ); - john.split(); - john.setSelection( [ 1, 0 ], [ 3, 1 ] ); - john.delete(); - john.setSelection( [ 1 ] ); - john.merge(); + john.remove(); + john.setSelection( [ 1 ] ); + john.merge(); - expectClients( 'Foo' ); + expectClients( 'Foo' ); - john.undo(); - expectClients( 'Foo' ); + john.undo(); - john.undo(); - expectClients( 'FooBar' ); + expectClients( 'Foo' ); - john.undo(); - expectClients( 'FooBar' ); + john.undo(); - john.undo(); - expectClients( 'FooBar' ); + expectClients( 'FooBar' ); + } ); - john.redo(); - expectClients( 'FooBar' ); + it( 'delete split paragraphs, then merge, undo, redo', () => { + john.setData( 'FooB[]ar' ); - john.redo(); - expectClients( 'FooBar' ); + john.split(); + john.setSelection( [ 2, 1 ] ); + john.split(); + john.setSelection( [ 1, 0 ], [ 3, 1 ] ); + john.delete(); + john.setSelection( [ 1 ] ); + john.merge(); - john.redo(); - expectClients( 'Foo' ); + expectClients( 'Foo' ); - john.redo(); - expectClients( 'Foo' ); - } ); + john.undo(); + expectClients( 'Foo' ); - it( 'pasting on collapsed selection undo and redo', () => { - john.setData( 'Foo[]Bar' ); + john.undo(); + expectClients( 'FooBar' ); - // Below simulates pasting. - john.editor.model.change( () => { - john.split(); - john.setSelection( [ 1 ] ); + john.undo(); + expectClients( 'FooBar' ); - john.insert( '1' ); - john.setSelection( [ 1 ] ); - john.merge(); + john.undo(); + expectClients( 'FooBar' ); - john.setSelection( [ 1 ] ); - john.insert( '2' ); - john.setSelection( [ 2 ] ); - john.merge(); + john.redo(); + expectClients( 'FooBar' ); + + john.redo(); + expectClients( 'FooBar' ); + + john.redo(); + expectClients( 'Foo' ); + + john.redo(); + expectClients( 'Foo' ); } ); - expectClients( 'Foo12Bar' ); + it( 'pasting on collapsed selection undo and redo', () => { + john.setData( 'Foo[]Bar' ); - john.undo(); - expectClients( 'FooBar' ); + // Below simulates pasting. + john.editor.model.change( () => { + john.split(); + john.setSelection( [ 1 ] ); - john.redo(); - expectClients( 'Foo12Bar' ); + john.insert( '1' ); + john.setSelection( [ 1 ] ); + john.merge(); - john.undo(); - expectClients( 'FooBar' ); + john.setSelection( [ 1 ] ); + john.insert( '2' ); + john.setSelection( [ 2 ] ); + john.merge(); + } ); - john.redo(); - expectClients( 'Foo12Bar' ); - } ); + expectClients( 'Foo12Bar' ); - // Following case was throwing before a fix was applied: - // - // [] is selection and a marker. It is copied and pasted between XY. And then undone: - // - //

A[A

B]B

XY

- //

A[A

B]B

X[A

B]Y

- //

A[A

B]B

XY

- // - it( 'paste with markers, undo, redo', () => { - john.setData( 'AABBX[]Y' ); - - john.editor.model.change( writer => { - // Simulate copy. Create a document fragment that includes expected copied fragment + marker. - // <$marker />AB<$marker /> - const docFrag = writer.createDocumentFragment(); - const pA = writer.createElement( 'paragraph' ); - const pB = writer.createElement( 'paragraph' ); - - writer.insertText( 'A', pA, 0 ); - writer.insertText( 'B', pB, 0 ); - writer.insert( pA, docFrag, 0 ); - writer.insert( pB, docFrag, 1 ); - - const marker = writer.createRange( writer.createPositionAt( pA, 0 ), writer.createPositionAt( pB, 1 ) ); - docFrag.markers.set( 'm', marker ); - - // Insert the document fragment (simulates paste). - john.setSelection( [ 2, 1 ], [ 2, 1 ] ); - john.insertContent( docFrag ); - } ); - - expectClients( - 'AA' + - 'BB' + - 'XA' + - 'BY' - ); - - john.undo(); - expectClients( 'AABBXY' ); - - john.redo(); - expectClients( - 'AA' + - 'BB' + - 'XA' + - 'BY' - ); - - john.undo(); - expectClients( 'AABBXY' ); - } ); + john.undo(); + expectClients( 'FooBar' ); - it( 'selection attribute setting: split, bold, merge, undo, undo, undo', () => { - // This test is ported from undo to keep 100% CC in engine. - john.setData( 'Foo[]Bar' ); + john.redo(); + expectClients( 'Foo12Bar' ); - john.split(); - john.setSelection( [ 1, 0 ] ); - john._processExecute( 'bold' ); - john._processExecute( 'deleteForward' ); + john.undo(); + expectClients( 'FooBar' ); - expectClients( 'FooBar' ); + john.redo(); + expectClients( 'Foo12Bar' ); + } ); - john.undo(); - expectClients( 'FooBar' ); + // Following case was throwing before a fix was applied: + // + // [] is selection and a marker. It is copied and pasted between XY. And then undone: + // + //

A[A

B]B

XY

+ //

A[A

B]B

X[A

B]Y

+ //

A[A

B]B

XY

+ // + it( 'paste with markers, undo, redo', () => { + john.setData( 'AABBX[]Y' ); + + john.editor.model.change( writer => { + // Simulate copy. Create a document fragment that includes expected copied fragment + marker. + // <$marker />AB<$marker /> + const docFrag = writer.createDocumentFragment(); + const pA = writer.createElement( 'paragraph' ); + const pB = writer.createElement( 'paragraph' ); + + writer.insertText( 'A', pA, 0 ); + writer.insertText( 'B', pB, 0 ); + writer.insert( pA, docFrag, 0 ); + writer.insert( pB, docFrag, 1 ); + + const marker = writer.createRange( writer.createPositionAt( pA, 0 ), writer.createPositionAt( pB, 1 ) ); + docFrag.markers.set( 'm', marker ); + + // Insert the document fragment (simulates paste). + john.setSelection( [ 2, 1 ], [ 2, 1 ] ); + john.insertContent( docFrag ); + } ); + + expectClients( + 'AA' + + 'BB' + + 'XA' + + 'BY' + ); - john.undo(); - expectClients( 'FooBar' ); + john.undo(); + expectClients( 'AABBXY' ); - john.undo(); - expectClients( 'FooBar' ); - } ); + john.redo(); + expectClients( + 'AA' + + 'BB' + + 'XA' + + 'BY' + ); - // https://github.com/ckeditor/ckeditor5/issues/1288 - it( 'remove two groups of blocks then undo, undo', () => { - john.setData( - 'XAB[CD]' - ); + john.undo(); + expectClients( 'AABBXY' ); + } ); - john.delete(); - john.setSelection( [ 0, 1 ], [ 2, 1 ] ); - john.delete(); + it( 'selection attribute setting: split, bold, merge, undo, undo, undo', () => { + // This test is ported from undo to keep 100% CC in engine. + john.setData( 'Foo[]Bar' ); - expectClients( 'X' ); + john.split(); + john.setSelection( [ 1, 0 ] ); + john._processExecute( 'bold' ); + john._processExecute( 'deleteForward' ); - john.undo(); + expectClients( 'FooBar' ); - expectClients( 'XAB' ); + john.undo(); + expectClients( 'FooBar' ); - john.undo(); + john.undo(); + expectClients( 'FooBar' ); - expectClients( - 'XABCD' - ); - } ); + john.undo(); + expectClients( 'FooBar' ); + } ); - // https://github.com/ckeditor/ckeditor5/issues/1287 TC1 - it( 'pasting on non-collapsed selection undo and redo', () => { - john.setData( 'Fo[oB]ar' ); + // https://github.com/ckeditor/ckeditor5/issues/1288 + it( 'remove two groups of blocks then undo, undo', () => { + john.setData( + 'XAB[CD]' + ); - // Below simulates pasting. - john.editor.model.change( () => { - john.editor.model.deleteContent( john.document.selection ); + john.delete(); + john.setSelection( [ 0, 1 ], [ 2, 1 ] ); + john.delete(); - john.setSelection( [ 0, 2 ] ); - john.split(); + expectClients( 'X' ); - john.setSelection( [ 1 ] ); - john.insert( '1' ); + john.undo(); - john.setSelection( [ 1 ] ); - john.merge(); + expectClients( 'XAB' ); - john.setSelection( [ 1 ] ); - john.insert( '2' ); + john.undo(); - john.setSelection( [ 2 ] ); - john.merge(); + expectClients( + 'XABCD' + ); } ); - expectClients( 'Fo12ar' ); + // https://github.com/ckeditor/ckeditor5/issues/1287 TC1 + it( 'pasting on non-collapsed selection undo and redo', () => { + john.setData( 'Fo[oB]ar' ); - john.undo(); - expectClients( 'FooBar' ); + // Below simulates pasting. + john.editor.model.change( () => { + john.editor.model.deleteContent( john.document.selection ); - john.redo(); - expectClients( 'Fo12ar' ); + john.setSelection( [ 0, 2 ] ); + john.split(); - john.undo(); - expectClients( 'FooBar' ); - } ); + john.setSelection( [ 1 ] ); + john.insert( '1' ); - it( 'collapsed marker at the beginning of merged element then undo', () => { - john.setData( 'Foo[]Bar' ); + john.setSelection( [ 1 ] ); + john.merge(); - john.setMarker( 'm1' ); - john.setSelection( [ 1 ] ); - john.merge(); + john.setSelection( [ 1 ] ); + john.insert( '2' ); - expectClients( 'FooBar' ); + john.setSelection( [ 2 ] ); + john.merge(); + } ); - john.undo(); + expectClients( 'Fo12ar' ); - expectClients( 'FooBar' ); - } ); + john.undo(); + expectClients( 'FooBar' ); - it( 'collapsed marker at the end of merge-target element then undo', () => { - john.setData( 'Foo[]Bar' ); + john.redo(); + expectClients( 'Fo12ar' ); - john.setMarker( 'm1' ); - john.setSelection( [ 1 ] ); - john.merge(); + john.undo(); + expectClients( 'FooBar' ); - expectClients( 'FooBar' ); + john.redo(); + expectClients( 'Fo12ar' ); + } ); - john.undo(); + it( 'collapsed marker at the beginning of merged element then undo', () => { + john.setData( 'Foo[]Bar' ); - expectClients( 'FooBar' ); - } ); + john.setMarker( 'm1' ); + john.setSelection( [ 1 ] ); + john.merge(); - it( 'empty marker between merged elements then undo', () => { - john.setData( 'Foo[]Bar' ); + expectClients( 'FooBar' ); - john.setMarker( 'm1' ); - john.setSelection( [ 1 ] ); - john.merge(); + john.undo(); - expectClients( 'FooBar' ); + expectClients( 'FooBar' ); + } ); - john.undo(); + it( 'collapsed marker at the end of merge-target element then undo', () => { + john.setData( 'Foo[]Bar' ); - expectClients( 'FooBar' ); - } ); + john.setMarker( 'm1' ); + john.setSelection( [ 1 ] ); + john.merge(); - it( 'left side of marker moved then undo', () => { - john.setData( 'Foo[bar]' ); + expectClients( 'FooBar' ); - john.setMarker( 'm1' ); - john.setSelection( [ 0, 2 ], [ 0, 4 ] ); - john.move( [ 1, 0 ] ); + john.undo(); - expectClients( 'Foarob' ); + expectClients( 'FooBar' ); + } ); - john.undo(); + it( 'empty marker between merged elements then undo', () => { + john.setData( 'Foo[]Bar' ); - expectClients( 'Foobar' ); - } ); + john.setMarker( 'm1' ); + john.setSelection( [ 1 ] ); + john.merge(); - it( 'right side of marker moved then undo', () => { - john.setData( '[Foo]bar' ); + expectClients( 'FooBar' ); - john.setMarker( 'm1' ); - john.setSelection( [ 0, 2 ], [ 0, 4 ] ); - john.move( [ 1, 0 ] ); + john.undo(); - expectClients( 'Foarob' ); + expectClients( 'FooBar' ); + } ); - john.undo(); + it( 'left side of marker moved then undo', () => { + john.setData( 'Foo[bar]' ); - expectClients( 'Foobar' ); - } ); + john.setMarker( 'm1' ); + john.setSelection( [ 0, 2 ], [ 0, 4 ] ); + john.move( [ 1, 0 ] ); - it( 'marker on closing and opening tag - remove multiple elements #1', () => { - john.setData( - 'Abc' + - 'Foo[' + - ']Bar' - ); + expectClients( 'Foarob' ); - john.setMarker( 'm1' ); - john.setSelection( [ 0, 1 ], [ 2, 2 ] ); - john._processExecute( 'delete' ); + john.undo(); - expectClients( 'Ar' ); + expectClients( 'Foobar' ); + } ); - john.undo(); + it( 'right side of marker moved then undo', () => { + john.setData( '[Foo]bar' ); - expectClients( - 'Abc' + - 'Foo' + - 'Bar' - ); - } ); + john.setMarker( 'm1' ); + john.setSelection( [ 0, 2 ], [ 0, 4 ] ); + john.move( [ 1, 0 ] ); - it( 'marker on closing and opening tag - remove multiple elements #2', () => { - john.setData( - 'Foo[' + - ']Bar' + - 'Xyz' - ); + expectClients( 'Foarob' ); - john.setMarker( 'm1' ); - john.setSelection( [ 0, 1 ], [ 2, 2 ] ); - john._processExecute( 'delete' ); + john.undo(); - expectClients( 'Fz' ); + expectClients( 'Foobar' ); + } ); - john.undo(); + it( 'marker on closing and opening tag - remove multiple elements #1', () => { + john.setData( + 'Abc' + + 'Foo[' + + ']Bar' + ); - expectClients( - 'Foo' + - 'Bar' + - 'Xyz' - ); - } ); + john.setMarker( 'm1' ); + john.setSelection( [ 0, 1 ], [ 2, 2 ] ); + john._processExecute( 'delete' ); + + expectClients( 'Ar' ); - it( 'marker on closing and opening tag + some text - merge elements + remove text', () => { - john.setData( - 'Foo[' + - 'B]ar' - ); + john.undo(); - john.setMarker( 'm1' ); - john.setSelection( [ 0, 1 ], [ 1, 2 ] ); - john._processExecute( 'delete' ); + expectClients( + 'Abc' + + 'Foo' + + 'Bar' + ); + } ); - expectClients( 'Fr' ); + it( 'marker on closing and opening tag - remove multiple elements #2', () => { + john.setData( + 'Foo[' + + ']Bar' + + 'Xyz' + ); - john.undo(); + john.setMarker( 'm1' ); + john.setSelection( [ 0, 1 ], [ 2, 2 ] ); + john._processExecute( 'delete' ); - expectClients( - 'Foo' + - 'Bar' - ); - } ); + expectClients( 'Fz' ); - // https://github.com/ckeditor/ckeditor5-engine/issues/1668 - it( 'marker and moves with undo-redo-undo', () => { - john.setData( 'X[]Y' ); + john.undo(); - const inputBufferBatch = john.editor.commands.get( 'input' ).buffer.batch; + expectClients( + 'Foo' + + 'Bar' + + 'Xyz' + ); + } ); - john.editor.model.enqueueChange( inputBufferBatch, () => { - john.type( 'a' ); - john.type( 'b' ); - john.type( 'c' ); + it( 'marker on closing and opening tag + some text - merge elements + remove text', () => { + john.setData( + 'Foo[' + + 'B]ar' + ); - john.setSelection( [ 0, 1 ], [ 0, 4 ] ); john.setMarker( 'm1' ); + john.setSelection( [ 0, 1 ], [ 1, 2 ] ); + john._processExecute( 'delete' ); + + expectClients( 'Fr' ); + + john.undo(); + + expectClients( + 'Foo' + + 'Bar' + ); } ); - expectClients( 'XabcY' ); + // https://github.com/ckeditor/ckeditor5-engine/issues/1668 + it( 'marker and moves with undo-redo-undo', () => { + john.setData( 'X[]Y' ); - john.setSelection( [ 0, 0 ], [ 0, 5 ] ); - john._processExecute( 'delete' ); + const inputBufferBatch = john.editor.commands.get( 'input' ).buffer.batch; - expectClients( '' ); + john.editor.model.enqueueChange( inputBufferBatch, () => { + john.type( 'a' ); + john.type( 'b' ); + john.type( 'c' ); - john.undo(); + john.setSelection( [ 0, 1 ], [ 0, 4 ] ); + john.setMarker( 'm1' ); + } ); - expectClients( 'XabcY' ); + expectClients( 'XabcY' ); - john.undo(); + john.setSelection( [ 0, 0 ], [ 0, 5 ] ); + john._processExecute( 'delete' ); - expectClients( 'XY' ); + expectClients( '' ); - john.redo(); + john.undo(); - expectClients( 'XabcY' ); + expectClients( 'XabcY' ); - john.redo(); + john.undo(); - expectClients( '' ); + expectClients( 'XY' ); - john.undo(); + john.redo(); - expectClients( 'XabcY' ); + expectClients( 'XabcY' ); - john.undo(); + john.redo(); - expectClients( 'XY' ); - } ); + expectClients( '' ); - // https://github.com/ckeditor/ckeditor5/issues/1385 - it( 'paste inside paste + undo, undo + redo, redo', () => { - const model = john.editor.model; + john.undo(); - john.setData( '[]' ); + expectClients( 'XabcY' ); - model.insertContent( getPastedContent() ); + john.undo(); - john.setSelection( [ 0, 3 ] ); + expectClients( 'XY' ); + } ); - model.insertContent( getPastedContent() ); + // https://github.com/ckeditor/ckeditor5/issues/1385 + it( 'paste inside paste + undo, undo + redo, redo', () => { + const model = john.editor.model; - expectClients( 'FooFoobarbar' ); + john.setData( '[]' ); - john.undo(); + model.insertContent( getPastedContent() ); - expectClients( 'Foobar' ); + john.setSelection( [ 0, 3 ] ); - john.undo(); + model.insertContent( getPastedContent() ); - expectClients( '' ); + expectClients( 'FooFoobarbar' ); - john.redo(); + john.undo(); - expectClients( 'Foobar' ); + expectClients( 'Foobar' ); - john.redo(); + john.undo(); - expectClients( 'FooFoobarbar' ); + expectClients( '' ); - function getPastedContent() { - return new Element( 'heading1', null, new Text( 'Foobar' ) ); - } - } ); + john.redo(); + + expectClients( 'Foobar' ); + + john.redo(); + + expectClients( 'FooFoobarbar' ); + + function getPastedContent() { + return new Element( 'heading1', null, new Text( 'Foobar' ) ); + } + } ); - // https://github.com/ckeditor/ckeditor5/issues/1540 - it( 'paste, select all, paste, undo, undo, redo, redo, redo', () => { - john.setData( '[]' ); + // https://github.com/ckeditor/ckeditor5/issues/1540 + it( 'paste, select all, paste, undo, undo, redo, redo, redo', () => { + john.setData( '[]' ); - pasteContent(); + pasteContent(); - john.setSelection( [ 0, 0 ], [ 1, 3 ] ); + john.setSelection( [ 0, 0 ], [ 1, 3 ] ); - pasteContent(); + pasteContent(); - expectClients( 'FooBar' ); + expectClients( 'FooBar' ); - john.undo(); + john.undo(); - expectClients( 'FooBar' ); + expectClients( 'FooBar' ); - john.undo(); + john.undo(); - expectClients( '' ); + expectClients( '' ); - john.redo(); + john.redo(); - expectClients( 'FooBar' ); + expectClients( 'FooBar' ); - john.redo(); + john.redo(); - expectClients( 'FooBar' ); + expectClients( 'FooBar' ); + + function pasteContent() { + john.editor.model.insertContent( + new DocumentFragment( [ + new Element( 'heading1', null, new Text( 'Foo' ) ), + new Element( 'paragraph', null, new Text( 'Bar' ) ) + ] ) + ); + } + } ); + + // Happens in track changes. Emulated here. + // https://github.com/ckeditor/ckeditor5-engine/issues/1701 + it( 'paste, remove, undo, undo, redo, redo', () => { + john.setData( 'Ab[]cdWxyz' ); - function pasteContent() { john.editor.model.insertContent( new DocumentFragment( [ - new Element( 'heading1', null, new Text( 'Foo' ) ), + new Element( 'paragraph', null, new Text( 'Foo' ) ), new Element( 'paragraph', null, new Text( 'Bar' ) ) ] ) ); - } - } ); - // Happens in track changes. Emulated here. - // https://github.com/ckeditor/ckeditor5-engine/issues/1701 - it( 'paste, remove, undo, undo, redo, redo', () => { - john.setData( 'Ab[]cdWxyz' ); + john.setSelection( [ 1, 3 ], [ 2, 2 ] ); + + john._processExecute( 'delete' ); - john.editor.model.insertContent( - new DocumentFragment( [ - new Element( 'paragraph', null, new Text( 'Foo' ) ), - new Element( 'paragraph', null, new Text( 'Bar' ) ) - ] ) - ); + expectClients( 'AbFooBaryz' ); - john.setSelection( [ 1, 3 ], [ 2, 2 ] ); + john.undo(); - john._processExecute( 'delete' ); + expectClients( 'AbFooBarcdWxyz' ); - expectClients( 'AbFooBaryz' ); + john.undo(); - john.undo(); + expectClients( 'AbcdWxyz' ); - expectClients( 'AbFooBarcdWxyz' ); + john.redo(); - john.undo(); + expectClients( 'AbFooBarcdWxyz' ); - expectClients( 'AbcdWxyz' ); + john.redo(); - john.redo(); + expectClients( 'AbFooBaryz' ); + } ); - expectClients( 'AbFooBarcdWxyz' ); + // https://github.com/ckeditor/ckeditor5/issues/8870 + it( 'object, p, p, p, remove, undo', () => { + john.setData( 'AB[]' ); + + // I couldn't use delete command because simply executing this command several times does not + // work correctly when selection reaches

[]

. + // At this point we have custom control for firing delete event on view and I didn't want to + // simulate that. + // + john.remove( [ 2, 0 ], [ 2, 1 ] ); + john.merge( [ 2 ] ); + john.remove( [ 1, 0 ], [ 1, 1 ] ); + john.remove( [ 1 ], [ 2 ] ); + john.remove( [ 0 ], [ 1 ] ); + + john.undo(); + john.undo(); + john.undo(); + john.undo(); + john.undo(); + + expectClients( 'AB' ); + } ); - john.redo(); + it( 'remove merged element then undo', () => { + john.setData( 'Foo[]Bar' ); - expectClients( 'AbFooBaryz' ); - } ); + john.merge(); + john.setSelection( [ 0, 0 ], [ 0, 6 ] ); + john.remove(); - // https://github.com/ckeditor/ckeditor5/issues/8870 - it( 'object, p, p, p, remove, undo', () => { - john.setData( 'AB[]' ); + expectClients( '' ); - // I couldn't use delete command because simply executing this command several times does not - // work correctly when selection reaches

[]

. - // At this point we have custom control for firing delete event on view and I didn't want to - // simulate that. - // - john.remove( [ 2, 0 ], [ 2, 1 ] ); - john.merge( [ 2 ] ); - john.remove( [ 1, 0 ], [ 1, 1 ] ); - john.remove( [ 1 ], [ 2 ] ); - john.remove( [ 0 ], [ 1 ] ); - - john.undo(); - john.undo(); - john.undo(); - john.undo(); - john.undo(); - - expectClients( 'AB' ); - } ); + john.undo(); + john.undo(); - it( 'remove merged element then undo', () => { - john.setData( 'Foo[]Bar' ); + expectClients( 'FooBar' ); + } ); - john.merge(); - john.setSelection( [ 0, 0 ], [ 0, 6 ] ); - john.remove(); + // https://github.com/cksource/ckeditor5-commercial/issues/4371. + it( 'add paragraphs and marker, remove marker, undo on both clients', async () => { + const kate = await Client.get( 'kate' ); - expectClients( '' ); + kate.setData( '[]' ); + john.setData( '[]' ); - john.undo(); - john.undo(); + syncClients(); - expectClients( 'FooBar' ); + kate._processExecute( 'insertText', { text: 'A' } ); + kate._processExecute( 'insertText', { text: 'B' } ); + kate._processExecute( 'insertText', { text: 'C' } ); + kate._processExecute( 'enter' ); + kate._processExecute( 'insertText', { text: 'X' } ); + kate._processExecute( 'insertText', { text: 'Y' } ); + kate._processExecute( 'insertText', { text: 'Z' } ); + kate.setSelection( [ 0, 1 ], [ 1, 2 ] ); + kate.setMarker( 'm1' ); + + syncClients(); + expectClients( 'ABCXYZ' ); + + john.setSelection( [ 0, 1 ], [ 1, 2 ] ); + john.delete(); + + syncClients(); + expectClients( 'AZ' ); + + kate.undo(); + + syncClients(); + expectClients( 'AZ' ); + + kate.undo(); + + syncClients(); + expectClients( 'A' ); + + john.undo(); + + syncClients(); + expectClients( 'ABCXY' ); + + return kate.destroy(); + } ); + + // https://github.com/cksource/ckeditor5-commercial/issues/4341 + it( 'split, type, remove on another client, undo, redo', async () => { + const kate = await Client.get( 'kate' ); + + kate.setData( 'Abc[]XyzFooBar' ); + john.setData( 'Abc[]XyzFooBar' ); + + kate._processExecute( 'enter' ); + kate._processExecute( 'insertText', { text: 'X' } ); + + syncClients(); + expectClients( 'AbcXXyz' + + 'FooBar' ); + + john.setSelection( [ 0, 0 ], [ 3, 3 ] ); + john._processExecute( 'delete' ); + + syncClients(); + expectClients( 'Bar' ); + + kate.undo(); + kate.undo(); + + syncClients(); + expectClients( 'Bar' ); + + kate.redo(); + kate.redo(); + + syncClients(); + expectClients( 'Bar' ); + + return kate.destroy(); + } ); + + // https://github.com/cksource/ckeditor5-commercial/issues/1939 + it( 'two intersecting removes through multiple elements, then undo', async () => { + const kate = await Client.get( 'kate' ); + + kate.setData( 'ABC[ABCABC]ABC' ); + john.setData( '[ABCABCABCABC]' ); + + john._processExecute( 'delete' ); + kate._processExecute( 'delete' ); + + syncClients(); + expectClients( '' ); + + john.undo(); + + syncClients(); + + // Actually, the *really* expected data is one paragraph less because `kate` removes it: + // + // ABCABC + // + // But during OT the merge operation becomes no operation at one point. + // + expectClients( 'ABCABC' ); + + return kate.destroy(); + } ); + + // https://github.com/ckeditor/ckeditor5/issues/9296 + it( 'multiple enters, then backspaces, then undo, redo', () => { + john.setData( 'AB[]CD' ); + + john.split(); + john.setSelection( [ 1, 0 ] ); + + john.split(); + john.setSelection( [ 2, 0 ] ); + + john.split(); + john.setSelection( [ 3, 0 ] ); + + expectClients( 'ABCD' ); + + john.delete(); + john.delete(); + john.delete(); + + expectClients( 'ABCD' ); + + john.undo(); + + expectClients( 'ABCD' ); + + john.undo(); + john.undo(); + john.undo(); + + expectClients( 'ABCD' ); + + john.redo(); + john.redo(); + john.redo(); + john.redo(); + + expectClients( 'ABCD' ); + } ); } ); } ); diff --git a/packages/ckeditor5-engine/tests/model/range.js b/packages/ckeditor5-engine/tests/model/range.js index 1a7295d801f..50f3564b83b 100644 --- a/packages/ckeditor5-engine/tests/model/range.js +++ b/packages/ckeditor5-engine/tests/model/range.js @@ -251,6 +251,13 @@ describe( 'Range', () => { expect( range.start.offset ).to.equal( 2 ); expect( range.end.offset ).to.equal( 9 ); } ); + + it( 'should combine ranges with reference range #2 - multiple backwards', () => { + const range = Range._createFromRanges( makeRanges( root, 4, 6, 3, 4, 2, 3, 6, 7, 1, 2 ) ); + + expect( range.start.offset ).to.equal( 1 ); + expect( range.end.offset ).to.equal( 7 ); + } ); } ); } );