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

Commit b26935c

Browse files
authoredFeb 19, 2018
Merge pull request #1286 from ckeditor/t/1281
Fix: `DocumenSelection#change:range` event will be fired only once after multiple selection live ranges have changed. Closes #1281.
2 parents 8eba5e9 + 8521dbe commit b26935c

File tree

3 files changed

+274
-8
lines changed

3 files changed

+274
-8
lines changed
 

‎src/model/documentselection.js

+31-8
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,16 @@ class LiveSelection extends Selection {
455455
// @member {Map} module:engine/model/liveselection~LiveSelection#_attributePriority
456456
this._attributePriority = new Map();
457457

458+
// Contains data required to fix ranges which have been moved to the graveyard.
459+
// @private
460+
// @member {Array} module:engine/model/liveselection~LiveSelection#_fixGraveyardRangesData
461+
this._fixGraveyardRangesData = [];
462+
463+
// Flag that informs whether the selection ranges have changed. It is changed on true when `LiveRange#change:range` event is fired.
464+
// @private
465+
// @member {Array} module:engine/model/liveselection~LiveSelection#_hasChangedRange
466+
this._hasChangedRange = false;
467+
458468
// Add events that will ensure selection correctness.
459469
this.on( 'change:range', () => {
460470
for ( const range of this.getRanges() ) {
@@ -497,6 +507,20 @@ class LiveSelection extends Selection {
497507
clearAttributesStoredInElement( operation, this._model, batch );
498508
}
499509
}, { priority: 'low' } );
510+
511+
this.listenTo( this._model, 'applyOperation', () => {
512+
while ( this._fixGraveyardRangesData.length ) {
513+
const { liveRange, sourcePosition } = this._fixGraveyardRangesData.shift();
514+
515+
this._fixGraveyardSelection( liveRange, sourcePosition );
516+
}
517+
518+
if ( this._hasChangedRange ) {
519+
this._hasChangedRange = false;
520+
521+
this.fire( 'change:range', { directChange: false } );
522+
}
523+
}, { priority: 'lowest' } );
500524
}
501525

502526
get isCollapsed() {
@@ -618,13 +642,15 @@ class LiveSelection extends Selection {
618642
const liveRange = LiveRange.createFromRange( range );
619643

620644
liveRange.on( 'change:range', ( evt, oldRange, data ) => {
621-
// If `LiveRange` is in whole moved to the graveyard, fix that range.
645+
this._hasChangedRange = true;
646+
647+
// If `LiveRange` is in whole moved to the graveyard, save necessary data. It will be fixed on `Model#applyOperation` event.
622648
if ( liveRange.root == this._document.graveyard ) {
623-
this._fixGraveyardSelection( liveRange, data.sourcePosition );
649+
this._fixGraveyardRangesData.push( {
650+
liveRange,
651+
sourcePosition: data.sourcePosition
652+
} );
624653
}
625-
626-
// Whenever a live range from selection changes, fire an event informing about that change.
627-
this.fire( 'change:range', { directChange: false } );
628654
} );
629655

630656
return liveRange;
@@ -890,9 +916,6 @@ class LiveSelection extends Selection {
890916
this._ranges.splice( index, 0, newRange );
891917
}
892918
// If nearest valid selection range cannot be found - just removing the old range is fine.
893-
894-
// Fire an event informing about selection change.
895-
this.fire( 'change:range', { directChange: false } );
896919
}
897920
}
898921

‎tests/model/documentselection.js

+27
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,33 @@ describe( 'DocumentSelection', () => {
740740
expect( selection.getFirstPosition().path ).to.deep.equal( [ 0, 0 ] );
741741
} );
742742
} );
743+
744+
it( '`DocumentSelection#change:range` event should be fire once even if selection contains multi-ranges', () => {
745+
root.removeChildren( 0, root.childCount );
746+
root.insertChildren( 0, [
747+
new Element( 'p', [], new Text( 'abcdef' ) ),
748+
new Element( 'p', [], new Text( 'foobar' ) ),
749+
new Text( 'xyz #2' )
750+
] );
751+
752+
selection._setTo( [
753+
Range.createIn( root.getNodeByPath( [ 0 ] ) ),
754+
Range.createIn( root.getNodeByPath( [ 1 ] ) )
755+
] );
756+
757+
spyRange = sinon.spy();
758+
selection.on( 'change:range', spyRange );
759+
760+
model.applyOperation( wrapInDelta(
761+
new InsertOperation(
762+
new Position( root, [ 0 ] ),
763+
'xyz #1',
764+
doc.version
765+
)
766+
) );
767+
768+
expect( spyRange.calledOnce ).to.be.true;
769+
} );
743770
} );
744771

745772
describe( 'attributes', () => {

‎tests/tickets/1281.js

+216
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/**
2+
* @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
3+
* For licensing, see LICENSE.md.
4+
*/
5+
6+
/* globals document */
7+
8+
import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
9+
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
10+
import Position from '../../src/model/position';
11+
12+
import { setData as setModelData, getData as getModelData } from '../../src/dev-utils/model';
13+
14+
describe( 'Bug ckeditor5-engine#1281', () => {
15+
let element, editor, model;
16+
17+
beforeEach( () => {
18+
element = document.createElement( 'div' );
19+
document.body.appendChild( element );
20+
21+
return ClassicTestEditor
22+
.create( element, { plugins: [ Paragraph ] } )
23+
.then( newEditor => {
24+
editor = newEditor;
25+
model = editor.model;
26+
} );
27+
} );
28+
29+
afterEach( () => {
30+
element.remove();
31+
32+
return editor.destroy();
33+
} );
34+
35+
it( 'loads content that contains multi-range selection', () => {
36+
setModelData( model,
37+
'<paragraph>Paragraph 1.</paragraph>' +
38+
'<paragraph>Paragraph 2.</paragraph>' +
39+
'<paragraph>[Paragraph 3.]</paragraph>' +
40+
'<paragraph>[Paragraph 4.]</paragraph>'
41+
);
42+
43+
const root = model.document.getRoot();
44+
const thirdParagraph = root.getNodeByPath( [ 2 ] );
45+
const fourthParagraph = root.getNodeByPath( [ 3 ] );
46+
const selRanges = Array.from( model.document.selection.getRanges() );
47+
48+
expect( selRanges.length ).to.equal( 2 );
49+
50+
assertPositions( Position.createAt( thirdParagraph ), selRanges[ 0 ].start );
51+
assertPositions( Position.createAt( thirdParagraph, 'end' ), selRanges[ 0 ].end );
52+
53+
assertPositions( Position.createAt( fourthParagraph ), selRanges[ 1 ].start );
54+
assertPositions( Position.createAt( fourthParagraph, 'end' ), selRanges[ 1 ].end );
55+
} );
56+
57+
it( 'does not throw an error when content before the selection is being removed (last element is selected)', () => {
58+
setModelData( model,
59+
'<paragraph>Paragraph 1.</paragraph>' +
60+
'<paragraph>Paragraph 2.</paragraph>' +
61+
'<paragraph>[Paragraph 3.]</paragraph>' +
62+
'<paragraph>[Paragraph 4.]</paragraph>'
63+
);
64+
65+
model.change( writer => {
66+
const root = model.document.getRoot();
67+
const firstParagraph = root.getNodeByPath( [ 0 ] );
68+
69+
expect( () => {
70+
writer.remove( firstParagraph );
71+
} ).to.not.throw();
72+
73+
assertOutput(
74+
'<paragraph>Paragraph 2.</paragraph>' +
75+
'<paragraph>[Paragraph 3.]</paragraph>' +
76+
'<paragraph>[Paragraph 4.]</paragraph>'
77+
);
78+
} );
79+
} );
80+
81+
it( 'does not throw an error when content before the selection is being removed (last element is not selected)', () => {
82+
setModelData( model,
83+
'<paragraph>Paragraph 1.</paragraph>' +
84+
'<paragraph>Paragraph 2.</paragraph>' +
85+
'<paragraph>[Paragraph 3.]</paragraph>' +
86+
'<paragraph>[Paragraph 4.]</paragraph>' +
87+
'<paragraph>Paragraph 5.</paragraph>'
88+
);
89+
90+
model.change( writer => {
91+
const root = model.document.getRoot();
92+
const firstParagraph = root.getNodeByPath( [ 0 ] );
93+
94+
expect( () => {
95+
writer.remove( firstParagraph );
96+
} ).to.not.throw();
97+
98+
assertOutput(
99+
'<paragraph>Paragraph 2.</paragraph>' +
100+
'<paragraph>[Paragraph 3.]</paragraph>' +
101+
'<paragraph>[Paragraph 4.]</paragraph>' +
102+
'<paragraph>Paragraph 5.</paragraph>'
103+
);
104+
} );
105+
} );
106+
107+
it( 'does not throw an error when content after the selection is being removed (first element is selected)', () => {
108+
setModelData( model,
109+
'<paragraph>[Paragraph 1.]</paragraph>' +
110+
'<paragraph>Paragraph 2.</paragraph>' +
111+
'<paragraph>Paragraph 3.</paragraph>' +
112+
'<paragraph>[Paragraph 4.]</paragraph>' +
113+
'<paragraph>Paragraph 5.</paragraph>'
114+
);
115+
116+
model.change( writer => {
117+
const root = model.document.getRoot();
118+
const lastParagraph = root.getNodeByPath( [ 4 ] );
119+
120+
expect( () => {
121+
writer.remove( lastParagraph );
122+
} ).to.not.throw();
123+
124+
assertOutput(
125+
'<paragraph>[Paragraph 1.]</paragraph>' +
126+
'<paragraph>Paragraph 2.</paragraph>' +
127+
'<paragraph>Paragraph 3.</paragraph>' +
128+
'<paragraph>[Paragraph 4.]</paragraph>'
129+
);
130+
} );
131+
} );
132+
133+
it( 'does not throw an error when content after the selection is being removed (first element is not selected)', () => {
134+
setModelData( model,
135+
'<paragraph>Paragraph 1.</paragraph>' +
136+
'<paragraph>Paragraph 2.</paragraph>' +
137+
'<paragraph>[Paragraph 3.]</paragraph>' +
138+
'<paragraph>[Paragraph 4.]</paragraph>' +
139+
'<paragraph>Paragraph 5.</paragraph>'
140+
);
141+
142+
model.change( writer => {
143+
const root = model.document.getRoot();
144+
const lastParagraph = root.getNodeByPath( [ 4 ] );
145+
146+
expect( () => {
147+
writer.remove( lastParagraph );
148+
} ).to.not.throw();
149+
150+
assertOutput(
151+
'<paragraph>Paragraph 1.</paragraph>' +
152+
'<paragraph>Paragraph 2.</paragraph>' +
153+
'<paragraph>[Paragraph 3.]</paragraph>' +
154+
'<paragraph>[Paragraph 4.]</paragraph>'
155+
);
156+
} );
157+
} );
158+
159+
it( 'does not throw an error when content between the selection\'s ranges is being removed (last element is selected)', () => {
160+
setModelData( model,
161+
'<paragraph>Paragraph 1.</paragraph>' +
162+
'<paragraph>[Paragraph 2.]</paragraph>' +
163+
'<paragraph>Paragraph 3.</paragraph>' +
164+
'<paragraph>[Paragraph 4.]</paragraph>'
165+
);
166+
167+
model.change( writer => {
168+
const root = model.document.getRoot();
169+
const thirdParagraph = root.getNodeByPath( [ 2 ] );
170+
171+
expect( () => {
172+
writer.remove( thirdParagraph );
173+
} ).to.not.throw();
174+
175+
assertOutput(
176+
'<paragraph>Paragraph 1.</paragraph>' +
177+
'<paragraph>[Paragraph 2.]</paragraph>' +
178+
'<paragraph>[Paragraph 4.]</paragraph>'
179+
);
180+
} );
181+
} );
182+
183+
it( 'does not throw an error when content between the selection\'s ranges is being removed (last element is not selected)', () => {
184+
setModelData( model,
185+
'<paragraph>Paragraph 1.</paragraph>' +
186+
'<paragraph>[Paragraph 2.]</paragraph>' +
187+
'<paragraph>Paragraph 3.</paragraph>' +
188+
'<paragraph>[Paragraph 4.]</paragraph>' +
189+
'<paragraph>Paragraph 5.</paragraph>'
190+
);
191+
192+
model.change( writer => {
193+
const root = model.document.getRoot();
194+
const thirdParagraph = root.getNodeByPath( [ 2 ] );
195+
196+
expect( () => {
197+
writer.remove( thirdParagraph );
198+
} ).to.not.throw();
199+
200+
assertOutput(
201+
'<paragraph>Paragraph 1.</paragraph>' +
202+
'<paragraph>[Paragraph 2.]</paragraph>' +
203+
'<paragraph>[Paragraph 4.]</paragraph>' +
204+
'<paragraph>Paragraph 5.</paragraph>'
205+
);
206+
} );
207+
} );
208+
209+
function assertPositions( firstPosition, secondPosition ) {
210+
expect( firstPosition.isEqual( secondPosition ) ).to.be.true;
211+
}
212+
213+
function assertOutput( output ) {
214+
expect( getModelData( model ) ).to.equal( output );
215+
}
216+
} );

0 commit comments

Comments
 (0)
This repository has been archived.