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

T/629b Alternative fix infinite selection loop. #671

Merged
merged 24 commits into from
Nov 16, 2016
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f4c0d25
Fixed: infinite selection fixing loop.
scofalik Nov 2, 2016
b9d6bc2
Fixed: clear infinite loop counter after user interaction.
scofalik Nov 2, 2016
1b39bfb
Fixed: view.Selection#isEqual better algorithm.
scofalik Nov 2, 2016
d2d1e73
Fixed: view.Selection#isEqual for fake selections.
scofalik Nov 2, 2016
a656829
Tests: fixed failed tests and 100% CC.
scofalik Nov 2, 2016
d80b367
Changed: SelectionObserver clearing infinite loop counter in time int…
scofalik Nov 9, 2016
317d3dc
Tests: SelectionObserver infinite loop removed unstable unit tests an…
scofalik Nov 9, 2016
f220109
Tests: updated manual test description.
scofalik Nov 9, 2016
a83c2ab
Merge branch 'master' into t/629b
Reinmar Nov 9, 2016
6b6da0c
Manual ticket tests should also be inside manual/ directory.
Reinmar Nov 9, 2016
3768f04
Tests: Improved cleanup.
Reinmar Nov 10, 2016
3dc704c
Tests: Cleaning listeners should not be necessary since we're disabli…
Reinmar Nov 10, 2016
a8e53ca
Tests: Fixed a selection observer test which could never work, but wa…
Reinmar Nov 10, 2016
dcf65b8
Added: Implemented destroy chain for EditingController->observers->do…
scofalik Nov 15, 2016
0de2bf7
Fixed: Selection#isEqual was throwing in the selection had no ranges.
scofalik Nov 15, 2016
25b8ba2
Docs: added/fixed code comments.
scofalik Nov 15, 2016
a3690d5
Tests: Additional tests for model.Selection and view.SelectionObserver.
scofalik Nov 15, 2016
b1e0e1f
Merge branch 'master' into t/629b
Reinmar Nov 15, 2016
adcdbdc
Changed: simplified implementation of model/view.Selection#isEqual.
scofalik Nov 16, 2016
6a2418b
Tests: added additional test for EditingController#destroy.
scofalik Nov 16, 2016
7ccaa13
Docs: added missing documentation.
scofalik Nov 16, 2016
bf9fc9f
Tests: expanded tests for model/view.Selection to cover more cases.
scofalik Nov 16, 2016
bf87840
Tests: Should not log a warning which is expected to be logged.
Reinmar Nov 16, 2016
6a9306e
Merge branch 'master' into t/629b
Reinmar Nov 16, 2016
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions src/conversion/view-selection-to-model-converters.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* @namespace engine.conversion.viewSelectionToModel
*/

import ModelSelection from '../model/selection.js';

/**
* Function factory, creates a callback function which converts a {@link engine.view.Selection view selection} taken
* from the {@link engine.view.Document#selectionChange} event and sets in on the {@link engine.model.Document#selection model}.
Expand All @@ -26,15 +28,21 @@
*/
export function convertSelectionChange( modelDocument, mapper ) {
return ( evt, data ) => {
modelDocument.enqueueChanges( () => {
const viewSelection = data.newSelection;
const ranges = [];
const viewSelection = data.newSelection;
const modelSelection = new ModelSelection();

const ranges = [];

for ( let viewRange of viewSelection.getRanges() ) {
ranges.push( mapper.toModelRange( viewRange ) );
}

for ( let viewRange of viewSelection.getRanges() ) {
ranges.push( mapper.toModelRange( viewRange ) );
}
modelSelection.setRanges( ranges, viewSelection.isBackward );

modelDocument.selection.setRanges( ranges, viewSelection.isBackward );
} );
if ( !modelSelection.isEqual( modelDocument.selection ) ) {
modelDocument.enqueueChanges( () => {
modelDocument.selection.setTo( modelSelection );
} );
}
};
}
22 changes: 13 additions & 9 deletions src/model/selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,25 +125,29 @@ export default class Selection {
}

/**
* Checks whether, this selection is equal to given selection. Selections equal if they have the same ranges and directions.
* Checks whether this selection is equal to given selection. Selections are equal if they have same directions,
* same number of ranges and all ranges from one selection equal to a range from other selection.
*
* @param {engine.model.Selection} otherSelection Selection to compare with.
* @returns {Boolean} `true` if selections are equal, `false` otherwise.
*/
isEqual( otherSelection ) {
const rangeCount = this.rangeCount;

if ( rangeCount != otherSelection.rangeCount ) {
if ( !this.anchor.isEqual( otherSelection.anchor ) || !this.focus.isEqual( otherSelection.focus ) ) {
return false;
}

for ( let i = 0; i < this.rangeCount; i++ ) {
if ( !this._ranges[ i ].isEqual( otherSelection._ranges[ i ] ) ) {
return false;
}
if ( this.rangeCount != otherSelection.rangeCount ) {
return false;
}

return this.isBackward === otherSelection.isBackward;
// Every range from this selection...
return Array.from( this.getRanges() ).every( ( rangeA ) => {
// ...Has a range in other selection...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// ... has :P

return Array.from( otherSelection.getRanges() ).some( ( rangeB ) => {
// That it is equal to.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// ... which it is equal to.

return rangeA.isEqual( rangeB );
} );
} );
}

/**
Expand Down
18 changes: 14 additions & 4 deletions src/view/observer/selectionobserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export default class SelectionObserver extends Observer {
}

domDocument.addEventListener( 'selectionchange', () => this._handleSelectionChange( domDocument ) );
domDocument.addEventListener( 'keydown', () => this._clearInfiniteLoop() );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

domDocument.addEventListener( 'mousemove', () => this._clearInfiniteLoop() );
domDocument.addEventListener( 'mousedown', () => this._clearInfiniteLoop() );

this._documents.add( domDocument );
}
Expand Down Expand Up @@ -151,10 +154,6 @@ export default class SelectionObserver extends Observer {
newSelection: newViewSelection,
domSelection: domSelection
} );

// If nothing changes on `selectionChange` event, at this point we have "dirty DOM" (changed) and de-synched
// view (which has not been changed). In order to "reset DOM" we render the view again.
this.document.render();
}

/**
Expand Down Expand Up @@ -185,6 +184,17 @@ export default class SelectionObserver extends Observer {

return false;
}

/**
* Clears `SelectionObserver` internal properties connected with preventing infinite loop.
*
* @private
*/
_clearInfiniteLoop() {
this._lastSelection = null;
this._lastButOneSelection = null;
this._loopbackCounter = 0;
}
}

/**
Expand Down
28 changes: 17 additions & 11 deletions src/view/selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -283,33 +283,39 @@ export default class Selection {
}

/**
* Checks whether, this selection is equal to given selection. Selections equal if they have the same ranges and directions.
* Checks whether, this selection is equal to given selection. Selections are equal if they have same directions,
* same number of ranges and all ranges from one selection equal to a range from other selection.
*
* @param {engine.view.Selection} otherSelection Selection to compare with.
* @returns {Boolean} `true` if selections are equal, `false` otherwise.
*/
isEqual( otherSelection ) {
const rangeCount = this.rangeCount;

if ( rangeCount != otherSelection.rangeCount ) {
if ( this.isFake != otherSelection.isFake ) {
return false;
}

if ( this.isFake != otherSelection.isFake ) {
if ( this.isFake && this.fakeSelectionLabel != otherSelection.fakeSelectionLabel ) {
return false;
}

if ( this.isFake && this.fakeSelectionLabel != otherSelection.fakeSelectionLabel ) {
if ( this.rangeCount != otherSelection.rangeCount ) {
return false;
} else if ( this.rangeCount === 0 ) {
return true;
}

for ( let i = 0; i < this.rangeCount; i++ ) {
if ( !this._ranges[ i ].isEqual( otherSelection._ranges[ i ] ) ) {
return false;
}
if ( !this.anchor.isEqual( otherSelection.anchor ) || !this.focus.isEqual( otherSelection.focus ) ) {
return false;
}

return this._lastRangeBackward === otherSelection._lastRangeBackward;
// Every range from this selection...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do I see correctly that the ranges can be in a different order? Why so? Then, the _lastRangeBackward property would need a more precise check. Besides, if we don't expect this to happen frequently, I'd consider these selections different.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backward/forward selection check is indirectly implemented when we check anchor and focus. Basically, if anchor and focus are equal and ranges are equal, this means that selections are equal. If the last range was added in different direction, anchor and focus would differ.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was rather asking why is this method trying to find a range in the other selection which matches range from this selection... for each range in this selection? Moreover, this implementation is very ineffective, cause it creates new array on every call of every() callback and makes huge number of comparisons (in a bit random way). And last but not least – since we're checking anchor and focus first, then checking ranges again will for a collapsed selection tripple the amount of work and for a single range non collapsed selection double it.

Therefore, I've been asking about the order of ranges (whether it shouldn't be the same in both selections) because the easiest possible implementation is to loop through both selections' ranges and compare the pairs. Not only this will be much faster, but also cleaner.

Copy link
Contributor Author

@scofalik scofalik Nov 16, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I see.

Ranges are not sorted upon insertion. This means that we have to check whether every range in selection A has a matching range in selection B the way we do. If the ranges are ordered that would be easier.

It is a matter of us deciding whether the order range is important. Being strict, I think that we should not care about the ranges order, because from "outside" selection: [ 1, 4 ], [ 5, 7 ], [ 8, 9 ] forward behaves exactly the same as [ 5, 7 ], [ 1, 4 ], [ 8, 9 ] forward. On the other hand, the amount of situations when this will be important is close to 0.

I can change the implementation but... To be honest selection will usually have just one range, except of tables where it may be more but in 99% scenarios it will will be less than 10-20 ranges. I agree with changing so the arrays are not created. If you want to change "order logic" I'm fine with that as well, just confirm please whether we are doing it or leaving it as is.

And last but not least – since we're checking anchor and focus first, then checking ranges again will for a collapsed selection triple.

So, instead one operation we have three? And it is me who get complaints about premature optimization? :)

Copy link
Contributor Author

@scofalik scofalik Nov 16, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was rather asking why is this method trying to find a range in the other selection which matches range from this selection... for each range in this selection

BTW. I don't know if you haven't got too confused...

For each range in selection A, we check if there is a matching range in selection B. The only way we can "speed" this up is if we already have sorted arrays... (or care about the order).

return Array.from( this.getRanges() ).every( ( rangeA ) => {
// ...Has a range in other selection...
return Array.from( otherSelection.getRanges() ).some( ( rangeB ) => {
// That it is equal to.
return rangeA.isEqual( rangeB );
} );
} );
}

/**
Expand Down
16 changes: 16 additions & 0 deletions tests/conversion/view-selection-to-model-converters.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,20 @@ describe( 'convertSelectionChange', () => {
expect( modelGetData( model ) ).to.equal( '<paragraph>f[o]o</paragraph><paragraph>b[a]r</paragraph>' );
expect( model.selection.isBackward ).to.true;
} );

it( 'should not enqueue changes if selection has not changed', () => {
const viewSelection = new ViewSelection();
viewSelection.addRange( ViewRange.createFromParentsAndOffsets(
viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 1 ) );

convertSelection( null, { newSelection: viewSelection } );

const spy = sinon.spy();

model.on( 'changesDone', spy );

convertSelection( null, { newSelection: viewSelection } );

expect( spy.called ).to.be.false;
} );
} );
2 changes: 1 addition & 1 deletion tests/model/selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,7 @@ describe( 'Selection', () => {
selection.addRange( range2 );

const otherSelection = new Selection();
otherSelection.addRange( range1 );
otherSelection.addRange( range2 );

expect( selection.isEqual( otherSelection ) ).to.be.false;
} );
Expand Down
97 changes: 58 additions & 39 deletions tests/view/observer/selectionobserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* For licensing, see LICENSE.md.
*/

/* globals setTimeout, Range, document */
/* globals setTimeout, Range, document, KeyboardEvent, MouseEvent */
/* bender-tags: view, browser-only */

import ViewRange from '/ckeditor5/engine/view/range.js';
Expand Down Expand Up @@ -162,71 +162,90 @@ describe( 'SelectionObserver', () => {
} );

it( 'should not be treated as an infinite loop if the position is different', ( done ) => {
let counter = 30;

const viewFoo = viewDocument.getRoot().getChild( 0 ).getChild( 0 );
viewDocument.selection.addRange( ViewRange.createFromParentsAndOffsets( viewFoo, 0, viewFoo, 0 ) );

let counter = 0;

const spy = testUtils.sinon.spy( log, 'warn' );

listenter.listenTo( viewDocument, 'selectionChange', () => {
counter--;
counter++;

if ( counter > 0 ) {
setTimeout( () => changeCollapsedDomSelection( counter ) );
} else {
done();
if ( counter < 15 ) {
setTimeout( changeCollapsedDomSelection, 100 );
}
} );

changeCollapsedDomSelection( counter );
} );
changeCollapsedDomSelection();

it( 'should not be treated as an infinite loop if it is less then 3 times', ( done ) => {
let counter = 3;
setTimeout( () => {
expect( spy.called ).to.be.false;
done();
}, 1500 );
} );

it( 'should not be treated as an infinite loop if selection is changed only few times', ( done ) => {
const viewFoo = viewDocument.getRoot().getChild( 0 ).getChild( 0 );
viewDocument.selection.addRange( ViewRange.createFromParentsAndOffsets( viewFoo, 0, viewFoo, 0 ) );

listenter.listenTo( viewDocument, 'selectionChange', () => {
counter--;
const spy = testUtils.sinon.spy( log, 'warn' );

if ( counter > 0 ) {
setTimeout( () => changeDomSelection() );
} else {
done();
}
} );
for ( let i = 0; i < 4; i++ ) {
changeDomSelection();
}

changeDomSelection();
setTimeout( () => {
expect( spy.called ).to.be.false;
done();
}, 1500 );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's a chance to avoid (or shorten) such tests, then please do.

} );

it( 'should call render after selection change which reset selection if it was not changed', ( done ) => {
const viewBar = viewDocument.getRoot().getChild( 1 ).getChild( 0 );
viewDocument.selection.addRange( ViewRange.createFromParentsAndOffsets( viewBar, 0, viewBar, 1 ) );
const events = {
keydown: KeyboardEvent,
mousedown: MouseEvent,
mousemove: MouseEvent
};

listenter.listenTo( viewDocument, 'selectionChange', () => {
setTimeout( () => {
const domSelection = document.getSelection();
for ( let event in events ) {
it( 'should not be treated as an infinite loop if change is triggered by ' + event + ' event', ( done ) => {
let counter = 0;

expect( domSelection.rangeCount ).to.equal( 1 );
const viewFoo = viewDocument.getRoot().getChild( 0 ).getChild( 0 );
viewDocument.selection.addRange( ViewRange.createFromParentsAndOffsets( viewFoo, 0, viewFoo, 0 ) );

const domRange = domSelection.getRangeAt( 0 );
const domBar = document.getElementById( 'main' ).childNodes[ 1 ].childNodes[ 0 ];
const spy = testUtils.sinon.spy( log, 'warn' );

expect( domRange.startContainer ).to.equal( domBar );
expect( domRange.startOffset ).to.equal( 0 );
expect( domRange.endContainer ).to.equal( domBar );
expect( domRange.endOffset ).to.equal( 1 );
listenter.listenTo( viewDocument, 'selectionChange', () => {
counter++;

done();
if ( counter < 15 ) {
setTimeout( () => {
document.dispatchEvent( new events[ event ]( event ) );
changeDomSelection();
}, 100 );
}
} );
} );

changeDomSelection();
} );
setTimeout( () => {
expect( spy.called ).to.be.false;
done();
}, 1000 );

document.dispatchEvent( new events[ event ]( event ) );
changeDomSelection();
} );
}
} );

function changeCollapsedDomSelection( pos = 1 ) {
function changeCollapsedDomSelection() {
const domSelection = document.getSelection();
const pos = domSelection.anchorOffset + 1;

if ( pos > 20 ) {
return;
}

domSelection.removeAllRanges();
const domFoo = document.getElementById( 'main' ).childNodes[ 0 ].childNodes[ 0 ];
const domRange = new Range();
Expand Down