/** * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ /* global document */ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; import TwoStepCaretMovement from '../src/twostepcaretmovement'; import Position from '@ckeditor/ckeditor5-engine/src/model/position'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import '@ckeditor/ckeditor5-core/tests/_utils/assertions/attribute'; describe( 'TwoStepCaretMovement()', () => { let editor, model, emitter, selection, view, plugin; let preventDefaultSpy, evtStopSpy; testUtils.createSinonSandbox(); beforeEach( () => { emitter = Object.create( DomEmitterMixin ); return VirtualTestEditor.create( { plugins: [ TwoStepCaretMovement ] } ).then( newEditor => { editor = newEditor; model = editor.model; selection = model.document.selection; view = editor.editing.view; plugin = editor.plugins.get( TwoStepCaretMovement ); preventDefaultSpy = sinon.spy().named( 'preventDefault' ); evtStopSpy = sinon.spy().named( 'evt.stop' ); editor.model.schema.extend( '$text', { allowAttributes: [ 'a', 'b', 'c' ], allowIn: '$root' } ); model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'a', model: 'a' } ); editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'b', model: 'b' } ); editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'c', model: 'c' } ); editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } ); plugin.registerAttribute( 'a' ); } ); } ); afterEach( () => { return editor.destroy(); } ); describe( 'moving right', () => { it( 'should do nothing for unrelated attribute (at the beginning)', () => { setData( model, '[]<$text c="true">foo' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 0 }, '→', { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 1 } ] ); } ); it( 'should do nothing for unrelated attribute (at the end)', () => { setData( model, '<$text c="true">foo[]' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 } ] ); } ); it( 'should "enter" the text with attribute in two steps', () => { setData( model, '<$text c="true">foo[]<$text a="true" b="true">bar' ); testTwoStepCaretMovement( [ // Gravity is not overridden, caret is at the beginning of the text but is "outside" of the text. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', // Gravity is overridden, caret movement is blocked, selection at the beginning but "inside" the text. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 }, '→', // Caret movement was not blocked this time (still once) so everything works normally. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 } ] ); } ); it( 'should "leave" the text with attribute in two steps', () => { setData( model, '<$text a="true" b="true">bar[]<$text c="true">foo' ); testTwoStepCaretMovement( [ // Gravity is not overridden, caret is at the end of the text but is "inside" of the text. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', // Gravity is overridden, caret movement is blocked, selection at the end but "outside" the text. { selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 }, '→', { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 } ] ); } ); it( 'should use two-steps movement when between nodes with the same attribute but different value', () => { setData( model, '<$text a="1">bar[]<$text a="2">foo' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 3 }, '→', // <$text a="1">bar<$text a="2">[]foo { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 3 }, '→', // <$text a="1">bar<$text a="2">f[]oo { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 4 } ] ); } ); // https://github.com/ckeditor/ckeditor5/issues/937 it( 'should not require two-steps between unrelated attributes inside the initial attribute', () => { setData( model, '<$text a="1">fo[]o<$text a="1" b="2">bar<$text a="1">baz' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 } ] ); } ); // https://github.com/ckeditor/ckeditor5-engine/issues/1301 it( 'should handle passing through the only character in the block', () => { setData( model, '<$text a="1">[]x' ); testTwoStepCaretMovement( [ // <$text a="1">[]x { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 0 }, '→', // <$text a="1">x[] { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 1 }, '→', // <$text a="1">x[] { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 1 }, '→', // Stays at <$text a="1">x[] { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 1 } ] ); } ); // https://github.com/ckeditor/ckeditor5-engine/issues/1301 it( 'should handle passing through the only character in the block (no attribute in the initial selection)', () => { setData( model, '[]<$text a="1">x' ); model.change( writer => writer.removeSelectionAttribute( 'a' ) ); testTwoStepCaretMovement( [ { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 }, '→', { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }, '→', { selectionAttributes: [], isGravityOverridden: true, preventDefault: 2, evtStop: 2 }, '→', { selectionAttributes: [], isGravityOverridden: true, preventDefault: 2, evtStop: 2 } ] ); } ); // https://github.com/ckeditor/ckeditor5-engine/issues/1301 it( 'should handle passing through the only-child with an attribute (multiple characters)', () => { setData( model, '[]<$text a="1">xyz' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', // <$text a="1">x{}yz { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', // <$text a="1">xy{}z { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', // <$text a="1">xyz{} { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', // <$text a="1">xyz{} { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1 }, '→', // <$text a="1">xyz{} { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1 } ] ); } ); it( 'should handle leaving an attribute followed by another block', () => { setData( model, '<$text a="1">foo[]<$text b="1">bar' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', // <$text a="1">bar[]foo { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1 }, '→', // <$text a="1">barf[]oo { selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }, '→', // <$text a="1">barfo[]o { selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 } ] ); } ); } ); describe( 'moving left', () => { it( 'should "enter" the text with attribute in two steps', () => { setData( model, '<$text>foo<$text a="true" b="true">bar<$text c="true">b[]iz' ); testTwoStepCaretMovement( [ // Gravity is not overridden, caret is a one character after the and of the text. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', // Caret movement was not blocked but the gravity is overridden. { selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0 }, '←', { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }, '←', { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 } ] ); } ); it( 'should "leave" the text with attribute in two steps', () => { setData( model, '<$text c="true">foo<$text a="true" b="true">b[]ar' ); testTwoStepCaretMovement( [ // Gravity is not overridden, caret is a one character after the beginning of the text. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', // Caret movement was not blocked. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0 }, '←', { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }, '←', { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 } ] ); } ); it( 'should do nothing for unrelated attribute (at the beginning)', () => { setData( model, '<$text c="true">[]foo' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 } ] ); } ); it( 'should do nothing for unrelated attribute (at the end)', () => { setData( model, '<$text c="true">foo[]' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 } ] ); } ); it( 'should do nothing when caret is at the beginning of block element', () => { setData( model, '[]foo', { lastRangeBackward: true } ); testTwoStepCaretMovement( [ { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 } ] ); } ); it( 'should require two-steps movement when caret goes between text node with the same attribute but different value', () => { setData( model, '<$text a="2">foo<$text a="1">b[]ar' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 4 }, '←', // <$text a="2">foo<$text a="1">[]bar { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0, caretPosition: 3 }, '←', // <$text a="2">foo[]<$text a="1">bar { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 3 } ] ); expect( selection ).to.have.attribute( 'a', 2 ); testTwoStepCaretMovement( [ '←', // <$text a="2">fo[]o<$text a="1">bar { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 2 } ] ); } ); // https://github.com/ckeditor/ckeditor5/issues/937 it( 'should not require two-steps between unrelated attributes inside the initial attribute', () => { setData( model, '<$text a="1">foo<$text a="1" b="2">bar<$text a="1">b[]az' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 } ] ); } ); // https://github.com/ckeditor/ckeditor5-engine/issues/1301 it( 'should handle passing through the only-child with an attribute (single character)', () => { setData( model, '<$text a="1">x[]' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 1 }, '←', // <$text a="1">{}x { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0, caretPosition: 0 }, '←', // {}<$text a="1">x (because it's a first-child) { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 0 }, '←', // {}<$text a="1">x { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 0 } ] ); } ); // https://github.com/ckeditor/ckeditor5-engine/issues/1301 it( 'should handle passing through the only character in the block (no attribute in the initial selection)', () => { setData( model, '<$text a="1">x[]' ); model.change( writer => writer.removeSelectionAttribute( 'a' ) ); testTwoStepCaretMovement( [ { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }, '←', { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 }, '←', { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStop: 2 }, '←', { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStop: 2 } ] ); } ); // https://github.com/ckeditor/ckeditor5-engine/issues/1301 it( 'should handle passing through the only-child with an attribute (single character, text before)', () => { setData( model, 'abc<$text a="1">x[]' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', // abc<$text a="1">{}x { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0 }, '←', // abc{}<$text a="1">x (because it's a first-child) { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }, '←', // abc{}<$text a="1">x { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 } ] ); } ); // https://github.com/ckeditor/ckeditor5-engine/issues/1301 it( 'should handle passing through the only-child with an attribute (multiple characters)', () => { setData( model, '<$text a="1">xyz[]' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', // <$text a="1">xy{}z { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', // <$text a="1">x{}yz { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', // <$text a="1">{}xyz { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0 }, '←', // {}<$text a="1">xyz { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }, '←', // {}<$text a="1">xyz { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 } ] ); } ); it( 'should handle leaving an attribute preceded by another block', () => { setData( model, '<$text b="1">foo<$text a="1">[]bar' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: [ 1, 0 ] }, '←', { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: [ 1, 0 ] }, '←', { selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: [ 0, 3 ] } ] ); } ); } ); describe( 'moving and typing around the attribute', () => { it( 'should handle typing after the attribute', () => { setData( model, '<$text a="1">x[]' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 1 }, 'y', // <$text a="1">xy[] { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 2 }, '→', // <$text a="1">xy[] { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 2 }, 'z', // <$text a="1">xyz[] { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 3 }, '←', // <$text a="1">xy[]z { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 2 }, '←', // <$text a="1">xy[]z { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStop: 2, caretPosition: 2 }, 'w', // <$text a="1">xyw[] { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStop: 2, caretPosition: 3 } ] ); } ); it( 'should handle typing before the attribute', () => { setData( model, '<$text a="1">[]x' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', // []<$text a="1">x { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }, 'z', // z[]<$text a="1">x { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }, 'x', // zx[]<$text a="1">x { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }, '→', // zx<$text a="1">[]x { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 2, evtStop: 2 }, 'a', // zx<$text a="1">a[]x { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStop: 2 } ] ); } ); // https://github.com/ckeditor/ckeditor5-engine/issues/1346 // https://github.com/ckeditor/ckeditor5/issues/946 it( 'should correctly re-renter the attribute', () => { setData( model, 'fo[]o <$text a="1">bar' ); testTwoStepCaretMovement( [ { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', // foo[] <$text a="1">bar { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', // foo []<$text a="1">bar { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', // foo <$text a="1">[]bar { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 }, '→', // foo <$text a="1">b[]ar { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }, '←', // foo <$text a="1">[]bar { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 }, '←', // foo []<$text a="1">bar { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStop: 2 }, '←', // foo[] <$text a="1">bar { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStop: 2 }, '←', // fo[]o <$text a="1">bar { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStop: 2 } ] ); } ); // https://github.com/ckeditor/ckeditor5/issues/922 it( 'should not lose the new attribute when typing (after)', () => { setData( model, '<$text a="1">x[]' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', // <$text a="1">x[] { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1 } ] ); model.change( writer => { writer.setSelectionAttribute( 'b', 1 ); } ); // <$text a="1">x<$text b="1">[] expect( selection ).to.have.property( 'isGravityOverridden', true ); expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] ); model.change( writer => { writer.insertText( 'yz', selection.getAttributes(), selection.getFirstPosition() ); } ); // <$text a="1">x<$text b="1">yz[] expect( selection ).to.have.property( 'isGravityOverridden', false ); expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] ); } ); // https://github.com/ckeditor/ckeditor5/issues/922 it( 'should not lose the new attribute when typing (before)', () => { setData( model, '<$text a="1">[]x' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', // []<$text a="1">x { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 } ] ); model.change( writer => { writer.setSelectionAttribute( 'b', 1 ); } ); // <$text b="1">[]<$text a="1">x expect( selection ).to.have.property( 'isGravityOverridden', false ); expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] ); model.change( writer => { writer.insertText( 'yz', selection.getAttributes(), selection.getFirstPosition() ); } ); // <$text b="1">yz[]<$text a="1">x expect( selection ).to.have.property( 'isGravityOverridden', false ); expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] ); } ); } ); describe( 'multiple attributes', () => { beforeEach( () => { plugin.registerAttribute( 'c' ); } ); it( 'should work with the two-step caret movement (moving right)', () => { setData( model, 'fo[]o<$text a="true">foo<$text a="true" c="true">bar<$text c="true">bazqux' ); testTwoStepCaretMovement( [ // fo[]o<$text a="true">foo<$text a="true" c="true">bar<$text c="true">bazqux { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', // foo[]<$text a="true">foo<$text a="true" c="true">bar<$text c="true">bazqux { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', // foo<$text a="true">[]foo<$text a="true" c="true">bar<$text c="true">bazqux { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 }, '→', '→', '→', // foo<$text a="true">foo[]<$text a="true" c="true">bar<$text c="true">bazqux { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }, '→', // foo<$text a="true">foo<$text a="true" c="true">[]bar<$text c="true">bazqux { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 2, evtStop: 2 }, '→', '→', '→', // foo<$text a="true">foo<$text a="true" c="true">bar[]<$text c="true">bazqux { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 2, evtStop: 2 }, '→', // foo<$text a="true">foo<$text a="true" c="true">bar<$text c="true">[]bazqux { selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 3, evtStop: 3 }, '→', '→', '→', // foo<$text a="true">foo<$text a="true" c="true">bar<$text c="true">baz[]qux { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 3, evtStop: 3 }, '→', // foo<$text a="true">foo<$text a="true" c="true">bar<$text c="true">baz[]qux { selectionAttributes: [], isGravityOverridden: true, preventDefault: 4, evtStop: 4 }, '→', // foo<$text a="true">foo<$text a="true" c="true">bar<$text c="true">bazq[]ux { selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStop: 4 } ] ); } ); it( 'should work with the two-step caret movement (moving left)', () => { setData( model, 'foo<$text a="true">foo<$text a="true" c="true">bar<$text c="true">bazq[]ux' ); testTwoStepCaretMovement( [ // foo<$text a="true">foo<$text a="true" c="true">bar<$text c="true">bazq[]ux { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '←', // foo<$text a="true">foo<$text a="true" c="true">bar<$text c="true">baz[]qux { selectionAttributes: [], isGravityOverridden: true, preventDefault: 0, evtStop: 0 }, '←', // foo<$text a="true">foo<$text a="true" c="true">bar<$text c="true">baz[]qux { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }, '←', '←', '←', // foo<$text a="true">foo<$text a="true" c="true">bar<$text c="true">[]bazqux { selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 }, '←', // foo<$text a="true">foo<$text a="true" c="true">bar[]<$text c="true">bazqux { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 2, evtStop: 2 }, '←', '←', '←', // foo<$text a="true">foo<$text a="true" c="true">[]bar<$text c="true">bazqux { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 2, evtStop: 2 }, '←', // foo<$text a="true">foo[]<$text a="true" c="true">bar<$text c="true">bazqux { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 3, evtStop: 3 }, '←', '←', '←', // foo<$text a="true">[]foo<$text a="true" c="true">bar<$text c="true">bazqux { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 3, evtStop: 3 }, '←', // foo[]<$text a="true">foo<$text a="true" c="true">bar<$text c="true">bazqux { selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStop: 4 }, '←', // fo[]o<$text a="true">foo<$text a="true" c="true">bar<$text c="true">bazqux { selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStop: 4 } ] ); } ); describe( 'when two elements ends at the same position', () => { it( 'moving the caret in should take 2 steps', () => { setData( model, 'foo<$text a="true">foo<$text a="true" c="true">barq[]ux' ); testTwoStepCaretMovement( [ // foo<$text a="true">foo<$text a="true" c="true">barq[]ux { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 10 }, '←', // foo<$text a="true">foo<$text a="true" c="true">bar[]qux { selectionAttributes: [], isGravityOverridden: true, preventDefault: 0, evtStop: 0, caretPosition: 9 }, '←', // foo<$text a="true">foo<$text a="true" c="true">bar[]qux { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 9 }, '←', // foo<$text a="true">foo<$text a="true" c="true">ba[]rqux { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 8 } ] ); } ); it( 'moving the caret out should take 2 steps', () => { setData( model, 'foo<$text a="true">foo<$text a="true" c="true">ba[]rqux' ); testTwoStepCaretMovement( [ // foo<$text a="true">foo<$text a="true" c="true">ba[]rqux { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 8 }, '→', // foo<$text a="true">foo<$text a="true" c="true">bar[]qux { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 9 }, '→', // foo<$text a="true">foo<$text a="true" c="true">bar[]qux { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 9 }, '→', // foo<$text a="true">foo<$text a="true" c="true">barq[]ux { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 10 } ] ); } ); } ); describe( 'when two elements starts at the same position', () => { it( 'moving the caret in should take 2 steps', () => { setData( model, 'fo[]o<$text a="true" c="true">bar<$text a="true">bazqux' ); testTwoStepCaretMovement( [ // fo[]o<$text a="true" c="true">bar<$text a="true">bazqux { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 2 }, '→', // foo[]<$text a="true" c="true">bar<$text a="true">bazqux { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 3 }, '→', // foo<$text a="true" c="true">[]bar<$text a="true">bazqux { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 3 }, '→', // foo<$text a="true" c="true">b[]ar<$text a="true">bazqux { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 4 } ] ); } ); it( 'moving the caret out should take 2 steps', () => { setData( model, 'foo<$text a="true" c="true">b[]ar<$text a="true">bazqux' ); testTwoStepCaretMovement( [ // foo<$text a="true" c="true">b[]ar<$text a="true">bazqux { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 4 }, '←', // foo<$text a="true" c="true">[]bar<$text a="true">bazqux { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0, caretPosition: 3 }, '←', // foo[]<$text a="true" c="true">bar<$text a="true">bazqux { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 3 }, '←', // fo[]o<$text a="true" c="true">bar<$text a="true">bazqux { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 2 } ] ); } ); } ); } ); describe( 'mouse', () => { it( 'should not override gravity when selection is placed at the beginning of text', () => { setData( model, '<$text a="true">[]foo' ); expect( selection ).to.have.property( 'isGravityOverridden', false ); } ); it( 'should not override gravity when selection is placed at the end of text', () => { setData( model, '<$text a="true">foo[]' ); expect( selection ).to.have.property( 'isGravityOverridden', false ); } ); } ); it( 'should listen with the high+1 priority on view.document#keydown', () => { const highestPrioritySpy = sinon.spy().named( 'highestPrioritySpy' ); const highPrioritySpy = sinon.spy().named( 'highPrioritySpy' ); const normalPrioritySpy = sinon.spy().named( 'normalPrioritySpy' ); setData( model, '<$text c="true">foo[]<$text a="true" b="true">bar' ); emitter.listenTo( view.document, 'keydown', highestPrioritySpy, { priority: 'highest' } ); emitter.listenTo( view.document, 'keydown', highPrioritySpy, { priority: 'high' } ); emitter.listenTo( view.document, 'keydown', normalPrioritySpy, { priority: 'normal' } ); fireKeyDownEvent( { keyCode: keyCodes.arrowright, preventDefault: preventDefaultSpy } ); expect( highestPrioritySpy ).to.be.calledOnce; expect( preventDefaultSpy ).to.be.calledImmediatelyAfter( highestPrioritySpy ); expect( highPrioritySpy ).not.to.be.called; expect( normalPrioritySpy ).not.to.be.called; } ); it( 'should do nothing when key other then arrow left and right is pressed', () => { setData( model, '<$text a="true">foo[]' ); expect( () => { fireKeyDownEvent( { keyCode: keyCodes.arrowup } ); } ).to.not.throw(); } ); it( 'should do nothing for non-collapsed selection', () => { setData( model, '<$text c="true">fo[o]<$text a="true" b="true">bar' ); fireKeyDownEvent( { keyCode: keyCodes.arrowright } ); expect( selection ).to.have.property( 'isGravityOverridden', false ); } ); it( 'should do nothing when shift key is pressed', () => { setData( model, '<$text c="true">foo<$text a="true" b="true">b[]ar' ); fireKeyDownEvent( { keyCode: keyCodes.arrowleft, shiftKey: true } ); expect( selection ).to.have.property( 'isGravityOverridden', false ); } ); it( 'should do nothing when alt key is pressed', () => { setData( model, '<$text c="true">foo<$text a="true" b="true">b[]ar' ); fireKeyDownEvent( { keyCode: keyCodes.arrowleft, altKey: true } ); expect( selection ).to.have.property( 'isGravityOverridden', false ); } ); it( 'should do nothing when ctrl key is pressed', () => { setData( model, '<$text c="true">foo<$text a="true" b="true">b[]ar' ); fireKeyDownEvent( { keyCode: keyCodes.arrowleft, ctrlKey: true } ); expect( selection ).to.have.property( 'isGravityOverridden', false ); } ); it( 'should do nothing when the not a direct selection change but at the attribute boundary', () => { setData( model, '<$text a="true">foo[]bar' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }, '→', { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1 } ] ); // Simulate an external text insertion BEFORE the user selection to trigger #change:range. model.enqueueChange( 'transparent', writer => { writer.insertText( 'x', selection.getFirstPosition().getShiftedBy( -2 ) ); } ); expect( selection ).to.have.property( 'isGravityOverridden', true ); expect( getSelectionAttributesArray( selection ) ).to.have.members( [] ); } ); describe( 'right–to–left content', () => { it( 'should use the opposite helper methods (RTL content direction)', () => { // let model; return VirtualTestEditor .create( { plugins: [ TwoStepCaretMovement ], language: { content: 'he' } } ) .then( newEditor => { model = newEditor.model; selection = model.document.selection; view = newEditor.editing.view; newEditor.model.schema.extend( '$text', { allowAttributes: [ 'a', 'b', 'c' ], allowIn: '$root' } ); model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); newEditor.conversion.for( 'upcast' ).elementToAttribute( { view: 'a', model: 'a' } ); newEditor.conversion.for( 'upcast' ).elementToAttribute( { view: 'b', model: 'b' } ); newEditor.conversion.for( 'upcast' ).elementToAttribute( { view: 'c', model: 'c' } ); newEditor.conversion.elementToElement( { model: 'paragraph', view: 'p' } ); newEditor.plugins.get( TwoStepCaretMovement ).registerAttribute( 'a' ); return newEditor; } ) .then( newEditor => { setData( model, '<$text>לזה[]<$text a="true">שיוצג' ); testTwoStepCaretMovement( [ { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 3 }, '←', { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 3 } ], 'rtl' ); preventDefaultSpy.resetHistory(); evtStopSpy.resetHistory(); setData( model, '<$text>לזה<$text a="true">ש[]יוצג' ); testTwoStepCaretMovement( [ { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 4 }, '→', { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0, caretPosition: 3 }, '→', { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 3 }, '→', { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 2 } ], 'rtl' ); return newEditor.destroy(); } ); } ); } ); const keyMap = { '→': 'arrowright', '←': 'arrowleft' }; function fireKeyDownEvent( options ) { const eventInfo = new EventInfo( view.document, 'keydown' ); const eventData = new DomEventData( view.document, { target: document.body }, options ); sinon.stub( eventInfo, 'stop' ).callsFake( evtStopSpy ); view.document.fire( eventInfo, eventData ); } function getSelectionAttributesArray( selection ) { return Array.from( selection.getAttributeKeys() ); } function testTwoStepCaretMovement( scenario, rtl ) { for ( const step of scenario ) { if ( typeof step == 'string' ) { // An arrow key pressed. Fire the view event and update the model selection. if ( keyMap[ step ] ) { let preventDefaultCalled; fireKeyDownEvent( { keyCode: keyCodes[ keyMap[ step ] ], preventDefault: () => { preventDefaultSpy(); preventDefaultCalled = true; } } ); const position = selection.getFirstPosition(); if ( !preventDefaultCalled ) { if ( step == '→' && !rtl || step == '←' && rtl ) { model.change( writer => { if ( position.isAtEnd ) { const nextBlock = position.parent.nextSibling; if ( nextBlock ) { writer.setSelection( Position._createAt( nextBlock, 0 ) ); } } else { writer.setSelection( selection.getFirstPosition().getShiftedBy( 1 ) ); } } ); } else if ( step == '←' && !rtl || step == '→' && rtl ) { model.change( writer => { if ( position.isAtStart ) { const previousBlock = position.parent.previousSibling; if ( previousBlock ) { writer.setSelection( Position._createAt( previousBlock, 'end' ) ); } } else { writer.setSelection( selection.getFirstPosition().getShiftedBy( -1 ) ); } } ); } } } // A regular key pressed. Type some text in the model. else { model.change( writer => { writer.insertText( step, selection.getAttributes(), selection.getFirstPosition() ); } ); } } // If not a key, then it's an assertion. else { const stepIndex = scenario.indexOf( step ); const stepString = `in step #${ stepIndex }`; let caretPosition = step.caretPosition; if ( caretPosition !== undefined ) { // Normalize position caretPosition = Array.isArray( step.caretPosition ) ? caretPosition : [ caretPosition ]; expect( selection.getFirstPosition(), `in step #${ stepIndex }, selection's first position` ) .to.have.deep.property( 'path', caretPosition ); } expect( getSelectionAttributesArray( selection ), `${ stepString }, selection's attributes` ) .to.have.members( step.selectionAttributes ); expect( selection, `${ stepString }, selection's gravity` ) .to.have.property( 'isGravityOverridden', step.isGravityOverridden ); expect( preventDefaultSpy, stepString ).to.have.callCount( step.preventDefault ); expect( evtStopSpy, stepString ).to.have.callCount( step.evtStop ); } } } } );