/** * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ /* globals setTimeout, document */ import ViewRange from '../../../src/view/range'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import ViewSelection from '../../../src/view/documentselection'; import View from '../../../src/view/view'; import SelectionObserver from '../../../src/view/observer/selectionobserver'; import FocusObserver from '../../../src/view/observer/focusobserver'; import log from '@ckeditor/ckeditor5-utils/src/log'; import createViewRoot from '../_utils/createroot'; import { parse } from '../../../src/dev-utils/view'; testUtils.createSinonSandbox(); describe( 'SelectionObserver', () => { let view, viewDocument, viewRoot, selectionObserver, domRoot, domMain, domDocument; beforeEach( done => { domDocument = document; domRoot = domDocument.createElement( 'div' ); domRoot.innerHTML = '
'; domMain = domRoot.childNodes[ 0 ]; domDocument.body.appendChild( domRoot ); view = new View(); viewDocument = view.document; createViewRoot( viewDocument ); view.attachDomRoot( domMain ); selectionObserver = view.getObserver( SelectionObserver ); viewRoot = viewDocument.getRoot(); view.change( writer => { viewRoot._appendChildren( parse( 'xxx' + 'yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy' ) ); writer.setSelection( null ); domDocument.getSelection().removeAllRanges(); viewDocument.isFocused = true; domMain.focus(); } ); selectionObserver.enable(); // Ensure selectionchange will not be fired. setTimeout( () => done(), 100 ); } ); afterEach( () => { domRoot.parentElement.removeChild( domRoot ); view.destroy(); } ); it( 'should fire selectionChange when it is the only change', done => { viewDocument.on( 'selectionChange', ( evt, data ) => { expect( data ).to.have.property( 'domSelection' ).that.equals( domDocument.getSelection() ); expect( data ).to.have.property( 'oldSelection' ).that.is.instanceof( ViewSelection ); expect( data.oldSelection.rangeCount ).to.equal( 0 ); expect( data ).to.have.property( 'newSelection' ).that.is.instanceof( ViewSelection ); expect( data.newSelection.rangeCount ).to.equal( 1 ); const newViewRange = data.newSelection.getFirstRange(); const viewFoo = viewDocument.getRoot().getChild( 1 ).getChild( 0 ); expect( newViewRange.start.parent ).to.equal( viewFoo ); expect( newViewRange.start.offset ).to.equal( 2 ); expect( newViewRange.end.parent ).to.equal( viewFoo ); expect( newViewRange.end.offset ).to.equal( 2 ); done(); } ); changeDomSelection(); } ); it( 'should add only one listener to one document', done => { // Add second roots to ensure that listener is added once. createViewRoot( viewDocument, 'div', 'additional' ); view.attachDomRoot( domDocument.getElementById( 'additional' ), 'additional' ); viewDocument.on( 'selectionChange', () => { done(); } ); changeDomSelection(); } ); it( 'should not fire selectionChange on render', done => { viewDocument.on( 'selectionChange', () => { throw 'selectionChange on render'; } ); setTimeout( done, 70 ); const viewBar = viewDocument.getRoot().getChild( 1 ).getChild( 0 ); view.change( writer => { writer.setSelection( ViewRange.createFromParentsAndOffsets( viewBar, 1, viewBar, 2 ) ); } ); } ); it( 'should not fired if observer is disabled', done => { view.getObserver( SelectionObserver ).disable(); viewDocument.on( 'selectionChange', () => { throw 'selectionChange on render'; } ); setTimeout( done, 70 ); changeDomSelection(); } ); it( 'should not fired if there is no focus', done => { viewDocument.isFocused = false; // changeDomSelection() may focus the editable element (happens on Chrome) // so cancel this because it sets the isFocused flag. viewDocument.on( 'focus', evt => evt.stop(), { priority: 'highest' } ); viewDocument.on( 'selectionChange', () => { // Validate the correctness of the test. May help tracking issue with this test. expect( viewDocument.isFocused ).to.be.false; throw 'selectionChange on render'; } ); setTimeout( done, 70 ); changeDomSelection(); } ); it( 'should fired if there is no focus but document is read-only', done => { const spy = sinon.spy(); viewDocument.isFocused = false; viewDocument.isReadOnly = true; // changeDomSelection() may focus the editable element (happens on Chrome) // so cancel this because it sets the isFocused flag. viewDocument.on( 'focus', evt => evt.stop(), { priority: 'highest' } ); viewDocument.on( 'selectionChange', spy ); setTimeout( () => { sinon.assert.called( spy ); done(); }, 70 ); changeDomSelection(); } ); it( 'should warn and not enter infinite loop', () => { let counter = 70; const viewFoo = viewDocument.getRoot().getChild( 0 ).getChild( 0 ); view.change( writer => { writer.setSelection( viewFoo, 0 ); } ); return new Promise( ( resolve, reject ) => { testUtils.sinon.stub( log, 'warn' ).callsFake( msg => { expect( msg ).to.match( /^selectionchange-infinite-loop/ ); resolve(); } ); viewDocument.on( 'selectionChangeDone', () => { if ( !counter ) { reject( new Error( 'Infinite loop warning was not logged.' ) ); } } ); while ( counter > 0 ) { changeDomSelection(); counter--; } } ); } ); 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._setTo( ViewRange.createFromParentsAndOffsets( viewFoo, 0, viewFoo, 0 ) ); const spy = testUtils.sinon.spy( log, 'warn' ); viewDocument.on( 'selectionChangeDone', () => { expect( spy.called ).to.be.false; done(); } ); for ( let i = 0; i < 10; i++ ) { changeDomSelection(); } } ); it( 'should not be treated as an infinite loop if changes are not often', () => { const clock = testUtils.sinon.useFakeTimers( { toFake: [ 'setInterval', 'clearInterval' ] } ); const stub = testUtils.sinon.stub( log, 'warn' ); // We need to recreate SelectionObserver, so it will use mocked setInterval. selectionObserver.disable(); selectionObserver.destroy(); view._observers.delete( SelectionObserver ); view.addObserver( SelectionObserver ); return doChanges() .then( doChanges ) .then( () => { sinon.assert.notCalled( stub ); clock.restore(); } ); function doChanges() { return new Promise( resolve => { viewDocument.once( 'selectionChangeDone', () => { clock.tick( 1100 ); resolve(); } ); for ( let i = 0; i < 50; i++ ) { changeDomSelection(); } } ); } } ); it( 'should fire `selectionChangeDone` event after selection stop changing', done => { const spy = sinon.spy(); viewDocument.on( 'selectionChangeDone', spy ); // Disable focus observer to not re-render view on each focus. view.getObserver( FocusObserver ).disable(); // Change selection. changeDomSelection(); // Wait 100ms. // Note that it's difficult/not possible to test lodash#debounce with sinon fake timers. // See: https://github.com/lodash/lodash/issues/304 setTimeout( () => { // Check if spy was called. expect( spy.notCalled ).to.true; // Change selection one more time. changeDomSelection(); // Wait 210ms (debounced function should be called). setTimeout( () => { const data = spy.firstCall.args[ 1 ]; expect( spy.calledOnce ).to.true; expect( data ).to.have.property( 'domSelection' ).to.equal( domDocument.getSelection() ); expect( data ).to.have.property( 'oldSelection' ).to.instanceof( ViewSelection ); expect( data.oldSelection.rangeCount ).to.equal( 0 ); expect( data ).to.have.property( 'newSelection' ).to.instanceof( ViewSelection ); expect( data.newSelection.rangeCount ).to.equal( 1 ); const newViewRange = data.newSelection.getFirstRange(); const viewFoo = viewDocument.getRoot().getChild( 1 ).getChild( 0 ); expect( newViewRange.start.parent ).to.equal( viewFoo ); expect( newViewRange.start.offset ).to.equal( 3 ); expect( newViewRange.end.parent ).to.equal( viewFoo ); expect( newViewRange.end.offset ).to.equal( 3 ); done(); }, 210 ); }, 100 ); } ); it( 'should not fire `selectionChangeDone` event when observer will be destroyed', done => { const spy = sinon.spy(); viewDocument.on( 'selectionChangeDone', spy ); // Change selection. changeDomSelection(); // Wait 100ms. // Note that it's difficult/not possible to test lodash#debounce with sinon fake timers. // See: https://github.com/lodash/lodash/issues/304 setTimeout( () => { // And destroy observer. selectionObserver.destroy(); // Wait another 110ms. setTimeout( () => { // Check that event won't be called. expect( spy.notCalled ).to.true; done(); }, 110 ); }, 100 ); } ); it( 'should re-render view if selections are similar if DOM selection is in incorrect place', done => { const sel = domDocument.getSelection(); const domParagraph = domMain.childNodes[ 0 ]; const domText = domParagraph.childNodes[ 0 ]; const domUI = domParagraph.childNodes[ 1 ]; // Add rendering on selectionChange event to check this feature. viewDocument.on( 'selectionChange', () => { // Manually set selection because no handlers are set for selectionChange event in this test. // Normally this is handled by view -> model -> view selection converters chain. const viewAnchor = view.domConverter.domPositionToView( sel.anchorNode, sel.anchorOffset ); const viewFocus = view.domConverter.domPositionToView( sel.focusNode, sel.focusOffset ); view.change( writer => { writer.setSelection( viewAnchor ); writer.setSelectionFocus( viewFocus ); } ); } ); viewDocument.once( 'selectionChange', () => { // 2. Selection change has been handled. selectionObserver.listenTo( domDocument, 'selectionchange', () => { // 4. Check if view was re-rendered. expect( view.render.called ).to.be.true; done(); }, { priority: 'lowest' } ); // 3. Now, collapse selection in similar position, but in UI element. // Current and new selection position are similar in view (but not equal!). // Also add a spy to `viewDocument#render` to see if view will be re-rendered. sel.collapse( domUI, 0 ); sinon.spy( view, 'render' ); // Some browsers like Safari won't allow to put selection inside empty ui element. // In that situation selection should stay in correct place. if ( sel.anchorNode !== domUI ) { expect( sel.anchorNode ).to.equal( domText ); expect( sel.anchorOffset ).to.equal( 3 ); expect( sel.isCollapsed ).to.be.true; done(); } }, { priority: 'lowest' } ); // 1. Collapse in a text node, before ui element, and wait for async selectionchange to fire selection change handling. sel.collapse( domText, 3 ); } ); function changeDomSelection() { const domSelection = domDocument.getSelection(); const domFoo = domMain.childNodes[ 1 ].childNodes[ 0 ]; const offset = domSelection.anchorOffset; domSelection.collapse( domFoo, offset == 2 ? 3 : 2 ); } } );