Skip to content
This repository was archived by the owner on Jun 26, 2020. It is now read-only.

Commit ea6c881

Browse files
authored
Merge pull request #892 from ckeditor/t/891
Fix: Reversed `ReinsertOperation` targets back to same graveyard holder from which the nodes were re-inserted. Closes #891.
2 parents 86ea5b5 + 97f8b51 commit ea6c881

File tree

6 files changed

+113
-95
lines changed

6 files changed

+113
-95
lines changed

src/model/operation/reinsertoperation.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,13 @@ export default class ReinsertOperation extends MoveOperation {
4545
* @returns {module:engine/model/operation/removeoperation~RemoveOperation}
4646
*/
4747
getReversed() {
48-
return new RemoveOperation( this.targetPosition, this.howMany, this.baseVersion + 1 );
48+
const removeOp = new RemoveOperation( this.targetPosition, this.howMany, this.baseVersion + 1 );
49+
50+
// Make sure that nodes are put back into the `$graveyardHolder` from which they got reinserted.
51+
removeOp.targetPosition = this.sourcePosition;
52+
removeOp._needsHolderElement = false;
53+
54+
return removeOp;
4955
}
5056

5157
/**

src/model/operation/removeoperation.js

Lines changed: 21 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,25 @@ export default class RemoveOperation extends MoveOperation {
3030
const graveyardPosition = new Position( graveyard, [ graveyard.maxOffset, 0 ] );
3131

3232
super( position, howMany, graveyardPosition, baseVersion );
33+
34+
/**
35+
* Flag informing whether this operation should insert "holder" element (`true`) or should move removed nodes
36+
* into existing "holder" element (`false`).
37+
*
38+
* The flag should be set to `true` for each "new" `RemoveOperation` that is each `RemoveOperation` originally
39+
* created to remove some nodes from document (most likely created through `Batch` API).
40+
*
41+
* The flag should be set to `false` for each `RemoveOperation` that got created by splitting the original
42+
* `RemoveOperation`, for example during operational transformation.
43+
*
44+
* The flag should be set to `false` whenever removing nodes that were re-inserted from graveyard. This will
45+
* ensure correctness of all other operations that might change something on those nodes. This will also ensure
46+
* that redundant empty graveyard holder elements are not created.
47+
*
48+
* @protected
49+
* @type {Boolean}
50+
*/
51+
this._needsHolderElement = true;
3352
}
3453

3554
/**
@@ -59,39 +78,6 @@ export default class RemoveOperation extends MoveOperation {
5978
this.targetPosition.path[ 0 ] = offset;
6079
}
6180

62-
/**
63-
* Flag informing whether this operation should insert "holder" element (`true`) or should move removed nodes
64-
* into existing "holder" element (`false`).
65-
*
66-
* It is `true` for each `RemoveOperation` that is the first `RemoveOperation` in it's delta that points to given holder element.
67-
* This way only one `RemoveOperation` in given delta will insert "holder" element.
68-
*
69-
* @protected
70-
* @type {Boolean}
71-
*/
72-
get _needsHolderElement() {
73-
if ( this.delta ) {
74-
// Let's look up all operations from this delta in the same order as they are in the delta.
75-
for ( let operation of this.delta.operations ) {
76-
// We are interested only in `RemoveOperation`s.
77-
if ( operation instanceof RemoveOperation ) {
78-
// If the first `RemoveOperation` in the delta is this operation, this operation
79-
// needs to insert holder element in the graveyard.
80-
if ( operation == this ) {
81-
return true;
82-
} else if ( operation._holderElementOffset == this._holderElementOffset ) {
83-
// If there is a `RemoveOperation` in this delta that "points" to the same holder element offset,
84-
// that operation will already insert holder element at that offset. We should not create another holder.
85-
return false;
86-
}
87-
}
88-
}
89-
}
90-
91-
// By default `RemoveOperation` needs holder element, so set it so, if the operation does not have delta.
92-
return true;
93-
}
94-
9581
/**
9682
* @inheritDoc
9783
* @returns {module:engine/model/operation/reinsertoperation~ReinsertOperation}
@@ -152,7 +138,9 @@ export default class RemoveOperation extends MoveOperation {
152138
let sourcePosition = Position.fromJSON( json.sourcePosition, document );
153139

154140
const removeOp = new RemoveOperation( sourcePosition, json.howMany, json.baseVersion );
141+
155142
removeOp.targetPosition = Position.fromJSON( json.targetPosition, document );
143+
removeOp._needsHolderElement = json._needsHolderElement;
156144

157145
return removeOp;
158146
}

src/model/operation/transform.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,11 @@ const ot = {
355355
);
356356

357357
result.isSticky = a.isSticky;
358-
result._holderElementOffset = a._holderElementOffset;
358+
359+
if ( a instanceof RemoveOperation ) {
360+
result._needsHolderElement = a._needsHolderElement;
361+
result._holderElementOffset = a._holderElementOffset;
362+
}
359363

360364
return [ result ];
361365
},
@@ -388,7 +392,7 @@ const ot = {
388392
const aTarget = a.targetPosition.path[ 0 ];
389393
const bTarget = b.targetPosition.path[ 0 ];
390394

391-
if ( aTarget >= bTarget && isStrong ) {
395+
if ( aTarget > bTarget || ( aTarget == bTarget && isStrong ) ) {
392396
// Do not change original operation!
393397
a = a.clone();
394398
a.targetPosition.path[ 0 ]++;
@@ -462,9 +466,10 @@ const ot = {
462466
}
463467
}
464468

465-
// At this point we transformed this operation's source ranges it means that nothing should be changed.
466-
// But since we need to return an instance of Operation we return an array with NoOperation.
467469
if ( ranges.length === 0 ) {
470+
// At this point we transformed this operation's source ranges it means that nothing should be changed.
471+
// But since we need to return an instance of Operation we return an array with NoOperation.
472+
468473
if ( a instanceof RemoveOperation ) {
469474
// If `a` operation was RemoveOperation, we cannot convert it to NoOperation.
470475
// This is because RemoveOperation creates a holder in graveyard.
@@ -492,7 +497,7 @@ const ot = {
492497
);
493498

494499
// Map transformed range(s) to operations and return them.
495-
return ranges.reverse().map( ( range ) => {
500+
return ranges.reverse().map( ( range, i ) => {
496501
// We want to keep correct operation class.
497502
let result = new a.constructor(
498503
range.start,
@@ -502,7 +507,13 @@ const ot = {
502507
);
503508

504509
result.isSticky = a.isSticky;
505-
result._holderElementOffset = a._holderElementOffset;
510+
511+
if ( a instanceof RemoveOperation ) {
512+
// Transformed `RemoveOperation` needs graveyard holder only when the original operation needed it.
513+
// If `RemoveOperation` got split into two or more operations, only first operation needs graveyard holder.
514+
result._needsHolderElement = a._needsHolderElement && i === 0;
515+
result._holderElementOffset = a._holderElementOffset;
516+
}
506517

507518
return result;
508519
} );

tests/model/operation/reinsertoperation.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,22 @@ describe( 'ReinsertOperation', () => {
6161
expect( clone.baseVersion ).to.equal( operation.baseVersion );
6262
} );
6363

64-
it( 'should create a RemoveOperation as a reverse', () => {
64+
it( 'should create a correct RemoveOperation as a reverse', () => {
65+
// Test reversed operation's target position.
66+
graveyard.appendChildren( new Element( '$graveyardHolder' ) );
67+
6568
let reverse = operation.getReversed();
6669

6770
expect( reverse ).to.be.an.instanceof( RemoveOperation );
6871
expect( reverse.baseVersion ).to.equal( 1 );
6972
expect( reverse.howMany ).to.equal( 2 );
7073
expect( reverse.sourcePosition.isEqual( rootPosition ) ).to.be.true;
71-
expect( reverse.targetPosition.root ).to.equal( graveyardPosition.root );
74+
75+
// Reversed `ReinsertOperation` should target back to the same graveyard holder.
76+
expect( reverse.targetPosition.isEqual( graveyardPosition ) ).to.be.true;
77+
78+
// Reversed `ReinsertOperation` should not create new graveyard holder.
79+
expect( reverse._needsHolderElement ).to.be.false;
7280
} );
7381

7482
it( 'should undo reinsert set of nodes by applying reverse operation', () => {

tests/model/operation/removeoperation.js

Lines changed: 17 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import MoveOperation from '../../../src/model/operation/moveoperation';
1010
import Position from '../../../src/model/position';
1111
import Text from '../../../src/model/text';
1212
import Element from '../../../src/model/element';
13-
import Delta from '../../../src/model/delta/delta';
1413
import { jsonParseStringify, wrapInDelta } from '../../../tests/model/_utils/utils';
1514

1615
describe( 'RemoveOperation', () => {
@@ -52,7 +51,7 @@ describe( 'RemoveOperation', () => {
5251
expect( operation ).to.be.instanceof( MoveOperation );
5352
} );
5453

55-
it( 'should remove set of nodes and append them to holder element in graveyard root', () => {
54+
it( 'should be able to remove set of nodes and append them to holder element in graveyard root', () => {
5655
root.insertChildren( 0, new Text( 'fozbar' ) );
5756

5857
doc.applyOperation( wrapInDelta(
@@ -71,64 +70,24 @@ describe( 'RemoveOperation', () => {
7170
expect( graveyard.getChild( 0 ).getChild( 0 ).data ).to.equal( 'zb' );
7271
} );
7372

74-
it( 'should create new holder element for remove operations in different deltas', () => {
73+
it( 'should be able to remove set of nodes and append them to existing element in graveyard root', () => {
7574
root.insertChildren( 0, new Text( 'fozbar' ) );
75+
graveyard.appendChildren( new Element( '$graveyardHolder' ) );
7676

77-
doc.applyOperation( wrapInDelta(
78-
new RemoveOperation(
79-
new Position( root, [ 0 ] ),
80-
1,
81-
doc.version
82-
)
83-
) );
84-
85-
doc.applyOperation( wrapInDelta(
86-
new RemoveOperation(
87-
new Position( root, [ 0 ] ),
88-
1,
89-
doc.version
90-
)
91-
) );
92-
93-
doc.applyOperation( wrapInDelta(
94-
new RemoveOperation(
95-
new Position( root, [ 0 ] ),
96-
1,
97-
doc.version
98-
)
99-
) );
100-
101-
expect( graveyard.maxOffset ).to.equal( 3 );
102-
expect( graveyard.getChild( 0 ).getChild( 0 ).data ).to.equal( 'f' );
103-
expect( graveyard.getChild( 1 ).getChild( 0 ).data ).to.equal( 'o' );
104-
expect( graveyard.getChild( 2 ).getChild( 0 ).data ).to.equal( 'z' );
105-
} );
106-
107-
it( 'should not create new holder element for remove operation if it was already created for given delta', () => {
108-
root.insertChildren( 0, new Text( 'fozbar' ) );
109-
110-
let delta = new Delta();
111-
112-
// This simulates i.e. RemoveOperation that got split into two operations during OT.
113-
let removeOpA = new RemoveOperation(
114-
new Position( root, [ 1 ] ),
115-
1,
116-
doc.version
117-
);
118-
let removeOpB = new RemoveOperation(
77+
const op = new RemoveOperation(
11978
new Position( root, [ 0 ] ),
12079
1,
121-
doc.version + 1
80+
doc.version
12281
);
12382

124-
delta.addOperation( removeOpA );
125-
delta.addOperation( removeOpB );
83+
// Manually set holder element properties.
84+
op._needsHolderElement = false;
85+
op._holderElementOffset = 0;
12686

127-
doc.applyOperation( removeOpA );
128-
doc.applyOperation( removeOpB );
87+
doc.applyOperation( wrapInDelta( op ) );
12988

130-
expect( graveyard.childCount ).to.equal( 1 );
131-
expect( graveyard.getChild( 0 ).getChild( 0 ).data ).to.equal( 'fo' );
89+
expect( graveyard.maxOffset ).to.equal( 1 );
90+
expect( graveyard.getChild( 0 ).getChild( 0 ).data ).to.equal( 'f' );
13291
} );
13392

13493
it( 'should create RemoveOperation with same parameters when cloned', () => {
@@ -197,6 +156,8 @@ describe( 'RemoveOperation', () => {
197156
doc.version
198157
);
199158

159+
op._needsHolderElement = false;
160+
200161
const serialized = jsonParseStringify( op );
201162

202163
expect( serialized ).to.deep.equal( {
@@ -205,7 +166,8 @@ describe( 'RemoveOperation', () => {
205166
howMany: 2,
206167
isSticky: false,
207168
sourcePosition: jsonParseStringify( op.sourcePosition ),
208-
targetPosition: jsonParseStringify( op.targetPosition )
169+
targetPosition: jsonParseStringify( op.targetPosition ),
170+
_needsHolderElement: false
209171
} );
210172
} );
211173
} );
@@ -218,6 +180,8 @@ describe( 'RemoveOperation', () => {
218180
doc.version
219181
);
220182

183+
op._needsHolderElement = false;
184+
221185
doc.graveyard.appendChildren( [ new Element( '$graveyardHolder' ), new Element( '$graveyardHolder' ) ] );
222186

223187
const serialized = jsonParseStringify( op );

tests/model/operation/transform.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2757,6 +2757,47 @@ describe( 'transform', () => {
27572757
} );
27582758

27592759
describe( 'RemoveOperation', () => {
2760+
describe( 'by InsertOperation', () => {
2761+
it( 'should not need new graveyard holder if original operation did not needed it either', () => {
2762+
let op = new RemoveOperation( new Position( root, [ 1 ] ), 1, baseVersion );
2763+
op._needsHolderElement = false;
2764+
2765+
let transformBy = new InsertOperation( new Position( root, [ 0 ] ), [ new Node() ], baseVersion );
2766+
2767+
let transOp = transform( op, transformBy )[ 0 ];
2768+
2769+
expect( transOp._needsHolderElement ).to.be.false;
2770+
} );
2771+
} );
2772+
2773+
describe( 'by MoveOperation', () => {
2774+
it( 'should create not more than RemoveOperation that needs new graveyard holder', () => {
2775+
let op = new RemoveOperation( new Position( root, [ 1 ] ), 4, baseVersion );
2776+
let transformBy = new MoveOperation( new Position( root, [ 0 ] ), 2, new Position( root, [ 8 ] ), baseVersion );
2777+
2778+
let transOp = transform( op, transformBy );
2779+
2780+
expect( transOp.length ).to.equal( 2 );
2781+
2782+
expect( transOp[ 0 ]._needsHolderElement ).to.be.true;
2783+
expect( transOp[ 1 ]._needsHolderElement ).to.be.false;
2784+
} );
2785+
2786+
it( 'should not need new graveyard holder if original operation did not needed it either', () => {
2787+
let op = new RemoveOperation( new Position( root, [ 1 ] ), 4, baseVersion );
2788+
op._needsHolderElement = false;
2789+
2790+
let transformBy = new MoveOperation( new Position( root, [ 0 ] ), 2, new Position( root, [ 8 ] ), baseVersion );
2791+
2792+
let transOp = transform( op, transformBy );
2793+
2794+
expect( transOp.length ).to.equal( 2 );
2795+
2796+
expect( transOp[ 0 ]._needsHolderElement ).to.be.false;
2797+
expect( transOp[ 1 ]._needsHolderElement ).to.be.false;
2798+
} );
2799+
} );
2800+
27602801
describe( 'by RemoveOperation', () => {
27612802
it( 'removes same nodes and transformed is weak: change howMany to 0', () => {
27622803
let position = new Position( root, [ 2, 1 ] );

0 commit comments

Comments
 (0)