From 0701abdede1c1f82ea9f50836ee01515db78d251 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Tue, 14 Mar 2023 20:35:28 +0100 Subject: [PATCH 1/5] The beforeInput:deleteContentBackward should try to check if the provided target ranges should be used. --- .../ckeditor5-typing/src/deleteobserver.ts | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/ckeditor5-typing/src/deleteobserver.ts b/packages/ckeditor5-typing/src/deleteobserver.ts index e7df6cc810e..8f724a68cbe 100644 --- a/packages/ckeditor5-typing/src/deleteobserver.ts +++ b/packages/ckeditor5-typing/src/deleteobserver.ts @@ -7,7 +7,13 @@ * @module typing/deleteobserver */ -import { env, keyCodes } from '@ckeditor/ckeditor5-utils'; +import { + env, + keyCodes, + isInsideCombinedSymbol, + isInsideEmojiSequence, + isInsideSurrogatePair +} from '@ckeditor/ckeditor5-utils'; import { BubblingEventInfo, DomEventData, @@ -18,6 +24,7 @@ import { type ViewDocumentKeyUpEvent, type ViewDocumentSelection, type ViewSelection, + type ViewRange, type View } from '@ckeditor/ckeditor5-engine'; @@ -170,17 +177,14 @@ export default class DeleteObserver extends Observer { // The default deletion unit for deleteContentBackward is a single code point // but on Android it sometimes passes a wider target range, so we need to change // the unit of deletion to include the whole range to be removed and not a single code point. - if ( env.isAndroid && inputType === 'deleteContentBackward' ) { + if ( inputType === 'deleteContentBackward' ) { // On Android, deleteContentBackward has sequence 1 by default. - deleteData.sequence = 1; + if ( env.isAndroid ) { + deleteData.sequence = 1; + } // IME wants more than a single character to be removed. - if ( - targetRanges.length == 1 && ( - targetRanges[ 0 ].start.parent != targetRanges[ 0 ].end.parent || - targetRanges[ 0 ].start.offset + 1 != targetRanges[ 0 ].end.offset - ) - ) { + if ( shouldUseTargetRanges( targetRanges ) ) { deleteData.unit = DELETE_SELECTION; deleteData.selectionToRemove = view.createSelection( targetRanges ); } @@ -315,3 +319,47 @@ function enableChromeWorkaround( observer: DeleteObserver ) { return keyCode == keyCodes.backspace ? DELETE_BACKWARD : DELETE_FORWARD; } } + +/** + * TODO + */ +function shouldUseTargetRanges( targetRanges: Array ): boolean { + // The collapsed target range could happen for example while deleting inside an inline filler + // (it's mapped to collapsed position before an inline filler). + if ( targetRanges.length != 1 || targetRanges[ 0 ].isCollapsed ) { + return false; + } + + const walker = targetRanges[ 0 ].getWalker( { + direction: 'backward', + singleCharacters: true, + ignoreElementEnd: true + } ); + + let count = 0; + + for ( const { nextPosition } of walker ) { + if ( !nextPosition.parent.is( '$text' ) ) { + count++; + } else { + const data = nextPosition.parent.data; + const offset = nextPosition.offset; + + if ( + isInsideSurrogatePair( data, offset ) || + isInsideCombinedSymbol( data, offset ) || + isInsideEmojiSequence( data, offset ) + ) { + continue; + } + + count++; + } + + if ( count > 1 ) { + return true; + } + } + + return false; +} From f6c1b6ba669cedb55539dec1a7fc13024db3a674 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Tue, 28 Mar 2023 19:25:58 +0200 Subject: [PATCH 2/5] Added fixer for the beforeInput target ranges, so they make sense after mapping to the model. --- .../src/controller/editingcontroller.ts | 38 ++++++++++++++++++- .../ckeditor5-engine/src/conversion/mapper.ts | 1 + .../src/model/utils/selection-post-fixer.ts | 2 +- .../ckeditor5-engine/src/view/domconverter.ts | 4 +- 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/ckeditor5-engine/src/controller/editingcontroller.ts b/packages/ckeditor5-engine/src/controller/editingcontroller.ts index 836fc7b9ef7..d0c50bddc2b 100644 --- a/packages/ckeditor5-engine/src/controller/editingcontroller.ts +++ b/packages/ckeditor5-engine/src/controller/editingcontroller.ts @@ -7,7 +7,11 @@ * @module engine/controller/editingcontroller */ -import { CKEditorError, ObservableMixin } from '@ckeditor/ckeditor5-utils'; +import { + CKEditorError, + ObservableMixin, + type GetCallback +} from '@ckeditor/ckeditor5-utils'; import RootEditableElement from '../view/rooteditableelement'; import View from '../view/view'; @@ -28,14 +32,18 @@ import { import { convertSelectionChange } from '../conversion/upcasthelpers'; +import { tryFixingRange } from '../model/utils/selection-post-fixer'; + import type { default as Model, AfterChangesEvent, BeforeChangesEvent } from '../model/model'; import type ModelItem from '../model/item'; import type ModelText from '../model/text'; import type ModelTextProxy from '../model/textproxy'; +import type Schema from '../model/schema'; import type { DocumentChangeEvent } from '../model/document'; import type { Marker } from '../model/markercollection'; import type { StylesProcessor } from '../view/stylesmap'; import type { ViewDocumentSelectionChangeEvent } from '../view/observer/selectionobserver'; +import type { ViewDocumentInputEvent } from '../view/observer/inputobserver'; // @if CK_DEBUG_ENGINE // const { dumpTrees, initDocumentDumping } = require( '../dev-utils/utils' ); @@ -115,6 +123,11 @@ export default class EditingController extends ObservableMixin() { convertSelectionChange( this.model, this.mapper ) ); + this.listenTo( this.view.document, 'beforeinput', + fixTargetRanges( this.mapper, this.model.schema ), + { priority: 'high' } + ); + // Attach default model converters. this.downcastDispatcher.on>( 'insert:$text', insertText(), { priority: 'lowest' } ); this.downcastDispatcher.on( 'insert', insertAttributesAndChildren(), { priority: 'lowest' } ); @@ -232,3 +245,26 @@ export default class EditingController extends ObservableMixin() { } ); } } + +/** + * TODO + */ +function fixTargetRanges( mapper: Mapper, schema: Schema ): GetCallback { + return ( evt, data ) => { + if ( !data.targetRanges ) { + return; + } + + for ( let i = 0; i < data.targetRanges.length; i++ ) { + const viewRange = data.targetRanges[ i ]; + const modelRange = mapper.toModelRange( viewRange ); + const correctedRange = tryFixingRange( modelRange, schema ); + + if ( !correctedRange || correctedRange.isEqual( modelRange ) ) { + continue; + } + + data.targetRanges[ i ] = mapper.toViewRange( correctedRange ); + } + }; +} diff --git a/packages/ckeditor5-engine/src/conversion/mapper.ts b/packages/ckeditor5-engine/src/conversion/mapper.ts index 07a2e835991..ab04cb63d0f 100644 --- a/packages/ckeditor5-engine/src/conversion/mapper.ts +++ b/packages/ckeditor5-engine/src/conversion/mapper.ts @@ -518,6 +518,7 @@ export default class Mapper extends EmitterMixin() { // If the position is a text it is simple ("ba|r" -> 2). if ( viewParent.is( '$text' ) ) { + // TODO throw if viewOffset is bigger than text node length? But this would explode in IME. return viewOffset; } diff --git a/packages/ckeditor5-engine/src/model/utils/selection-post-fixer.ts b/packages/ckeditor5-engine/src/model/utils/selection-post-fixer.ts index a3545b7936a..5b146f574b6 100644 --- a/packages/ckeditor5-engine/src/model/utils/selection-post-fixer.ts +++ b/packages/ckeditor5-engine/src/model/utils/selection-post-fixer.ts @@ -116,7 +116,7 @@ function selectionPostFixer( writer: Writer, model: Model ): boolean { * * @returns Returns fixed range or null if range is valid. */ -function tryFixingRange( range: Range, schema: Schema ) { +export function tryFixingRange( range: Range, schema: Schema ): Range | null { if ( range.isCollapsed ) { return tryFixingCollapsedRange( range, schema ); } diff --git a/packages/ckeditor5-engine/src/view/domconverter.ts b/packages/ckeditor5-engine/src/view/domconverter.ts index 616f1a46493..1928d99dfce 100644 --- a/packages/ckeditor5-engine/src/view/domconverter.ts +++ b/packages/ckeditor5-engine/src/view/domconverter.ts @@ -852,6 +852,7 @@ export default class DomConverter { offset = offset < 0 ? 0 : offset; } + // TODO throw or return null if offset is bigger than text node length? But this would explode in IME. return new ViewPosition( viewParent, offset ); } // domParent instanceof HTMLElement. @@ -865,7 +866,8 @@ export default class DomConverter { } else { const domBefore = domParent.childNodes[ domOffset - 1 ]; - if ( isText( domBefore ) && isInlineFiller( domBefore ) ) { + // Jump over an inline filler (and also on Firefox jump over a block filler while pressing backspace in an empty paragraph). + if ( isText( domBefore ) && isInlineFiller( domBefore ) || this.isBlockFiller( domBefore ) ) { return this.domPositionToView( domBefore.parentNode!, indexOf( domBefore ) ); } From cd848179f26acb976d2025cba1f37887ab645928 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Tue, 4 Apr 2023 19:59:58 +0200 Subject: [PATCH 3/5] Cleaning code, adding tests. --- .../src/controller/editingcontroller.ts | 5 ++++- packages/ckeditor5-engine/src/conversion/mapper.ts | 1 - .../src/model/utils/selection-post-fixer.ts | 2 ++ packages/ckeditor5-engine/src/view/domconverter.ts | 1 - .../tests/view/domconverter/dom-to-view.js | 13 +++++++++++++ packages/ckeditor5-typing/src/deleteobserver.ts | 9 +++++---- 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/ckeditor5-engine/src/controller/editingcontroller.ts b/packages/ckeditor5-engine/src/controller/editingcontroller.ts index d0c50bddc2b..e499f294b7f 100644 --- a/packages/ckeditor5-engine/src/controller/editingcontroller.ts +++ b/packages/ckeditor5-engine/src/controller/editingcontroller.ts @@ -123,6 +123,7 @@ export default class EditingController extends ObservableMixin() { convertSelectionChange( this.model, this.mapper ) ); + // Fix `beforeinput` target ranges so that they map to the valid model ranges. this.listenTo( this.view.document, 'beforeinput', fixTargetRanges( this.mapper, this.model.schema ), { priority: 'high' } @@ -247,7 +248,9 @@ export default class EditingController extends ObservableMixin() { } /** - * TODO + * Checks whether the target ranges provided by the `beforeInput` event can be properly mapped to model ranges and fixes them if needed. + * + * This is using the same logic as the selection post-fixer. */ function fixTargetRanges( mapper: Mapper, schema: Schema ): GetCallback { return ( evt, data ) => { diff --git a/packages/ckeditor5-engine/src/conversion/mapper.ts b/packages/ckeditor5-engine/src/conversion/mapper.ts index ab04cb63d0f..07a2e835991 100644 --- a/packages/ckeditor5-engine/src/conversion/mapper.ts +++ b/packages/ckeditor5-engine/src/conversion/mapper.ts @@ -518,7 +518,6 @@ export default class Mapper extends EmitterMixin() { // If the position is a text it is simple ("ba|r" -> 2). if ( viewParent.is( '$text' ) ) { - // TODO throw if viewOffset is bigger than text node length? But this would explode in IME. return viewOffset; } diff --git a/packages/ckeditor5-engine/src/model/utils/selection-post-fixer.ts b/packages/ckeditor5-engine/src/model/utils/selection-post-fixer.ts index 5b146f574b6..357225d2ca3 100644 --- a/packages/ckeditor5-engine/src/model/utils/selection-post-fixer.ts +++ b/packages/ckeditor5-engine/src/model/utils/selection-post-fixer.ts @@ -114,6 +114,8 @@ function selectionPostFixer( writer: Writer, model: Model ): boolean { /** * Tries fixing a range if it's incorrect. * + * **Note:** This helper is used by the selection post-fixer and to fix the `beforeinput` target ranges. + * * @returns Returns fixed range or null if range is valid. */ export function tryFixingRange( range: Range, schema: Schema ): Range | null { diff --git a/packages/ckeditor5-engine/src/view/domconverter.ts b/packages/ckeditor5-engine/src/view/domconverter.ts index 1928d99dfce..000f16b43c4 100644 --- a/packages/ckeditor5-engine/src/view/domconverter.ts +++ b/packages/ckeditor5-engine/src/view/domconverter.ts @@ -852,7 +852,6 @@ export default class DomConverter { offset = offset < 0 ? 0 : offset; } - // TODO throw or return null if offset is bigger than text node length? But this would explode in IME. return new ViewPosition( viewParent, offset ); } // domParent instanceof HTMLElement. diff --git a/packages/ckeditor5-engine/tests/view/domconverter/dom-to-view.js b/packages/ckeditor5-engine/tests/view/domconverter/dom-to-view.js index 7e24a991a2b..ef3a3f4a16f 100644 --- a/packages/ckeditor5-engine/tests/view/domconverter/dom-to-view.js +++ b/packages/ckeditor5-engine/tests/view/domconverter/dom-to-view.js @@ -923,6 +923,19 @@ describe( 'DomConverter', () => { expect( stringify( viewP, viewPosition ) ).to.equal( '



[]

' ); } ); + it( 'should convert position after a block filler', () => { + const domFiller = BR_FILLER( document ); // eslint-disable-line new-cap + const domP = createElement( document, 'p', null, [ domFiller ] ); + + const viewP = parse( '

' ); + + converter.bindElements( domP, viewP ); + + const viewPosition = converter.domPositionToView( domP, 1 ); + + expect( stringify( viewP, viewPosition ) ).to.equal( '

[]

' ); + } ); + it( 'should return null if there is no corresponding parent node', () => { const domText = document.createTextNode( 'foo' ); const domP = createElement( document, 'p', null, domText ); diff --git a/packages/ckeditor5-typing/src/deleteobserver.ts b/packages/ckeditor5-typing/src/deleteobserver.ts index a6e8435e7c5..c29b4582ac1 100644 --- a/packages/ckeditor5-typing/src/deleteobserver.ts +++ b/packages/ckeditor5-typing/src/deleteobserver.ts @@ -175,15 +175,14 @@ export default class DeleteObserver extends Observer { } // The default deletion unit for deleteContentBackward is a single code point - // but on Android it sometimes passes a wider target range, so we need to change - // the unit of deletion to include the whole range to be removed and not a single code point. + // but if the browser provides a wider target range then we should use it. if ( inputType === 'deleteContentBackward' ) { // On Android, deleteContentBackward has sequence 1 by default. if ( env.isAndroid ) { deleteData.sequence = 1; } - // IME wants more than a single character to be removed. + // The beforeInput event wants more than a single character to be removed. if ( shouldUseTargetRanges( targetRanges ) ) { deleteData.unit = DELETE_SELECTION; deleteData.selectionToRemove = view.createSelection( targetRanges ); @@ -326,7 +325,7 @@ function enableChromeWorkaround( observer: DeleteObserver ) { } /** - * TODO + * Verifies if the given target ranges cover more than a single character and should be used instead of single code-point deletion. */ function shouldUseTargetRanges( targetRanges: Array ): boolean { // The collapsed target range could happen for example while deleting inside an inline filler @@ -344,12 +343,14 @@ function shouldUseTargetRanges( targetRanges: Array ): boolean { let count = 0; for ( const { nextPosition } of walker ) { + // There is some element in the range so count it as a single character. if ( !nextPosition.parent.is( '$text' ) ) { count++; } else { const data = nextPosition.parent.data; const offset = nextPosition.offset; + // Count combined symbols and emoji sequences as a single character. if ( isInsideSurrogatePair( data, offset ) || isInsideCombinedSymbol( data, offset ) || From 9fd2be806d75b514a04febf021d50a0885739c08 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Wed, 5 Apr 2023 18:26:00 +0200 Subject: [PATCH 4/5] Adding tests. --- .../tests/controller/editingcontroller.js | 113 ++++++- .../ckeditor5-typing/tests/deleteobserver.js | 306 +++++++++++------- 2 files changed, 299 insertions(+), 120 deletions(-) diff --git a/packages/ckeditor5-engine/tests/controller/editingcontroller.js b/packages/ckeditor5-engine/tests/controller/editingcontroller.js index e0f2666a793..45d5091de89 100644 --- a/packages/ckeditor5-engine/tests/controller/editingcontroller.js +++ b/packages/ckeditor5-engine/tests/controller/editingcontroller.js @@ -20,11 +20,15 @@ import ModelPosition from '../../src/model/position'; import ModelRange from '../../src/model/range'; import ModelDocumentFragment from '../../src/model/documentfragment'; -import { getData as getModelData, parse } from '../../src/dev-utils/model'; +import { getData as getModelData, setData as setModelData, parse } from '../../src/dev-utils/model'; import { getData as getViewData } from '../../src/dev-utils/view'; import { StylesProcessor } from '../../src/view/stylesmap'; import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { Typing } from '@ckeditor/ckeditor5-typing'; +import { Enter } from '@ckeditor/ckeditor5-enter'; describe( 'EditingController', () => { describe( 'constructor()', () => { @@ -626,4 +630,111 @@ describe( 'EditingController', () => { sinon.assert.calledOnce( changeSpy ); } ); } ); + + describe( 'beforeInput target ranges fixing', () => { + let editor, model, view, viewDocument, viewRoot, beforeInputSpy, deleteSpy; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { plugins: [ Typing, Enter ] } ); + model = editor.model; + view = editor.editing.view; + viewDocument = view.document; + viewRoot = viewDocument.getRoot(); + + beforeInputSpy = testUtils.sinon.spy(); + viewDocument.on( 'beforeinput', beforeInputSpy ); + + deleteSpy = testUtils.sinon.spy(); + viewDocument.on( 'delete', deleteSpy ); + + // Stub `editor.editing.view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. + sinon.stub( editor.editing.view, 'scrollToTheSelection' ); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should not fix flat range on text', () => { + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } ); + + setModelData( model, 'foobar[]' ); + + const targetRanges = [ view.createRange( + view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 3 ), + view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 6 ) + ) ]; + + const eventData = { + inputType: 'deleteContentBackward', + targetRanges + }; + + fireBeforeInputEvent( eventData ); + + // Verify beforeinput range. + sinon.assert.calledOnce( beforeInputSpy ); + + const inputData = beforeInputSpy.args[ 0 ][ 1 ]; + expect( inputData.inputType ).to.equal( eventData.inputType ); + expect( inputData.targetRanges ).to.deep.equal( eventData.targetRanges ); + + // Verify delete event. + sinon.assert.calledOnce( deleteSpy ); + + const deleteData = deleteSpy.args[ 0 ][ 1 ]; + + expect( deleteData.selectionToRemove.getFirstRange().isEqual( targetRanges[ 0 ] ) ).to.be.true; + expect( getModelData( model ) ).to.equal( 'foo[]' ); + } ); + + it.skip( 'should fix range that ends in block object', () => { + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } ); + + model.schema.register( 'blockObject', { inheritAllFrom: '$blockObject' } ); + editor.conversion.elementToElement( { model: 'blockObject', view: 'div' } ); + + setModelData( model, '[]foobar' ); + + const targetRanges = [ view.createRange( + view.createPositionAt( viewRoot.getChild( 0 ), 0 ), + view.createPositionAt( viewRoot.getChild( 1 ).getChild( 0 ), 0 ) + ) ]; + + const eventData = { + inputType: 'deleteContentBackward', + targetRanges + }; + + fireBeforeInputEvent( eventData ); + + // Verify beforeinput range. + sinon.assert.calledOnce( beforeInputSpy ); + + const inputData = beforeInputSpy.args[ 0 ][ 1 ]; + expect( inputData.inputType ).to.equal( eventData.inputType ); + expect( inputData.targetRanges[ 0 ] ).to.deep.equal( targetRanges[ 0 ] ); + + // Verify delete event. + sinon.assert.calledOnce( deleteSpy ); + + const deleteData = deleteSpy.args[ 0 ][ 1 ]; + + expect( deleteData.selectionToRemove.getFirstRange().isEqual( targetRanges[ 0 ] ) ).to.be.true; + expect( getModelData( model ) ).to.equal( 'foo[]' ); + } ); + + function fireBeforeInputEvent( eventData ) { + viewDocument.fire( 'beforeinput', { + domEvent: { + preventDefault() {} + }, + ...eventData + } ); + } + } ); } ); diff --git a/packages/ckeditor5-typing/tests/deleteobserver.js b/packages/ckeditor5-typing/tests/deleteobserver.js index c605b4def5e..ea326cb5262 100644 --- a/packages/ckeditor5-typing/tests/deleteobserver.js +++ b/packages/ckeditor5-typing/tests/deleteobserver.js @@ -19,18 +19,20 @@ import { StylesProcessor } from '@ckeditor/ckeditor5-engine/src/view/stylesmap'; describe( 'Delete', () => { describe( 'DeleteObserver', () => { - let view, domRoot, viewDocument; + let view, domRoot, viewRoot, viewDocument; let deleteSpy; testUtils.createSinonSandbox(); beforeEach( () => { domRoot = document.createElement( 'div' ); + domRoot.contenteditable = true; + document.body.appendChild( domRoot ); view = new View(); viewDocument = view.document; - createViewRoot( viewDocument ); + viewRoot = createViewRoot( viewDocument ); view.attachDomRoot( domRoot ); view.addObserver( DeleteObserver ); @@ -217,6 +219,28 @@ describe( 'Delete', () => { } ); } ); + it( 'should handle the deleteContentBackward event type and fire the delete event on Android', () => { + testUtils.sinon.stub( env, 'isAndroid' ).value( true ); + + viewSetData( view, '

f{o}o

' ); + + const viewRange = view.document.selection.getFirstRange(); + const domRange = view.domConverter.viewRangeToDom( viewRange ); + + fireBeforeInputDomEvent( domRoot, { + inputType: 'deleteContentBackward', + ranges: [ domRange ] + } ); + + sinon.assert.calledOnce( deleteSpy ); + sinon.assert.calledWithMatch( deleteSpy, {}, { + unit: 'codePoint', + direction: 'backward', + sequence: 1, + selectionToRemove: undefined + } ); + } ); + it( 'should handle the deleteWordBackward event type and fire the delete event', () => { viewSetData( view, '

fo{}o

' ); @@ -373,163 +397,207 @@ describe( 'Delete', () => { } ); } ); - describe( 'in Android environment (with some quirks)', () => { - let domElement, viewRoot, viewText; + describe( 'using event target ranges (deleteContentBackward)', () => { + it( 'should not use target ranges if it should remove a single character', () => { + viewSetData( view, '

fo{o}

' ); - beforeEach( () => { - // Force the the Android mode. - testUtils.sinon.stub( env, 'isAndroid' ).value( true ); + const viewRange = view.document.selection.getFirstRange(); + const domRange = view.domConverter.viewRangeToDom( viewRange ); - domElement = document.createElement( 'div' ); - domElement.contenteditable = true; - - document.body.appendChild( domElement ); - - view = new View(); - viewDocument = view.document; - view.addObserver( DeleteObserver ); + fireBeforeInputDomEvent( domRoot, { + inputType: 'deleteContentBackward', + ranges: [ domRange ] + } ); - viewRoot = createViewRoot( viewDocument ); - view.attachDomRoot( domElement ); + sinon.assert.calledOnce( deleteSpy ); + sinon.assert.calledWithMatch( deleteSpy, {}, { + unit: 'codePoint', + direction: 'backward', + sequence: 0, + selectionToRemove: undefined + } ); + } ); - //

foo

- view.change( writer => { - const p = writer.createContainerElement( 'p' ); - const text = writer.createText( 'foo' ); + it( 'should not use target ranges if it should remove a single code point from a combined symbol', () => { + viewSetData( view, '

foo{ã}

' ); + const viewRange = view.document.selection.getFirstRange(); + const domRange = view.domConverter.viewRangeToDom( viewRange ); - writer.insert( writer.createPositionAt( viewRoot, 0 ), p ); - writer.insert( writer.createPositionAt( p, 0 ), text ); + fireBeforeInputDomEvent( domRoot, { + inputType: 'deleteContentBackward', + ranges: [ domRange ] } ); - viewText = viewRoot.getChild( 0 ).getChild( 0 ); - } ); - - afterEach( () => { - domElement.remove(); + sinon.assert.calledOnce( deleteSpy ); + sinon.assert.calledWithMatch( deleteSpy, {}, { + unit: 'codePoint', + direction: 'backward', + sequence: 0, + selectionToRemove: undefined + } ); } ); - describe( 'delete event', () => { - it( 'should be fired on beforeinput', () => { - const spy = sinon.spy(); + it( 'should set selectionToRemove if target ranges include more than a single character', () => { + viewSetData( view, '

f{oo}

' ); + const viewRange = view.document.selection.getFirstRange(); + const domRange = view.domConverter.viewRangeToDom( viewRange ); - viewDocument.on( 'delete', spy ); + fireBeforeInputDomEvent( domRoot, { + inputType: 'deleteContentBackward', + ranges: [ domRange ] + } ); - viewDocument.fire( 'beforeinput', new DomEventData( viewDocument, getDomEvent(), { - domTarget: domElement, - inputType: 'deleteContentBackward', - targetRanges: [ - view.createRange( view.createPositionAt( viewText, 1 ), view.createPositionAt( viewText, 2 ) ) - ] - } ) ); - - expect( spy.calledOnce ).to.be.true; - - const data = spy.args[ 0 ][ 1 ]; - expect( data ).to.have.property( 'direction', 'backward' ); - expect( data ).to.have.property( 'unit', 'codePoint' ); - expect( data ).to.have.property( 'sequence', 1 ); - expect( data ).not.to.have.property( 'selectionToRemove' ); + sinon.assert.calledOnce( deleteSpy ); + sinon.assert.calledWithMatch( deleteSpy, {}, { + unit: 'selection', + direction: 'backward', + sequence: 0 } ); - it( 'should set selectionToRemove if target ranges size is different than 1', () => { - // In real scenarios, before `beforeinput` is fired, browser changes DOM selection to a selection that contains - // all content that should be deleted. If the selection is big (> 1 character) we need to pass special parameter - // so that `DeleteCommand` will know what to delete. This test checks that case. - const spy = sinon.spy(); + const data = deleteSpy.args[ 0 ][ 1 ]; + const range = data.selectionToRemove.getFirstRange(); + const viewText = viewRoot.getChild( 0 ).getChild( 0 ); - viewDocument.on( 'delete', spy ); + expect( range.start.offset ).to.equal( 1 ); + expect( range.start.parent ).to.equal( viewText ); + expect( range.end.offset ).to.equal( 3 ); + expect( range.end.parent ).to.equal( viewText ); + } ); - viewDocument.fire( 'beforeinput', new DomEventData( viewDocument, getDomEvent(), { - domTarget: domElement, - inputType: 'deleteContentBackward', - targetRanges: [ - view.createRange( view.createPositionAt( viewText, 0 ), view.createPositionAt( viewText, 3 ) ) - ] - } ) ); + it( 'should not use target ranges if it should remove a single emoji sequence', () => { + viewSetData( view, '

foo{๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง}

' ); + const viewRange = view.document.selection.getFirstRange(); + const domRange = view.domConverter.viewRangeToDom( viewRange ); - expect( spy.calledOnce ).to.be.true; + fireBeforeInputDomEvent( domRoot, { + inputType: 'deleteContentBackward', + ranges: [ domRange ] + } ); - const data = spy.args[ 0 ][ 1 ]; - expect( data ).to.have.property( 'selectionToRemove' ); + sinon.assert.calledOnce( deleteSpy ); + sinon.assert.calledWithMatch( deleteSpy, {}, { + unit: 'codePoint', + direction: 'backward', + sequence: 0, + selectionToRemove: undefined + } ); + } ); - const range = data.selectionToRemove.getFirstRange(); + it( 'should use target ranges if it should remove more than a emoji sequence', () => { + viewSetData( view, '

foo{๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง}

' ); + const viewRange = view.document.selection.getFirstRange(); + const domRange = view.domConverter.viewRangeToDom( viewRange ); - expect( range.start.offset ).to.equal( 0 ); - expect( range.start.parent ).to.equal( viewText ); - expect( range.end.offset ).to.equal( 3 ); - expect( range.end.parent ).to.equal( viewText ); + fireBeforeInputDomEvent( domRoot, { + inputType: 'deleteContentBackward', + ranges: [ domRange ] } ); - it( 'should set selectionToRemove if target ranges spans different parent nodes', () => { - // In real scenarios, before `beforeinput` is fired, browser changes DOM selection to a selection that contains - // all content that should be deleted. If the selection is big (> 1 character) we need to pass special parameter - // so that `DeleteCommand` will know what to delete. This test checks that case. - const spy = sinon.spy(); + sinon.assert.calledOnce( deleteSpy ); + sinon.assert.calledWithMatch( deleteSpy, {}, { + unit: 'selection', + direction: 'backward', + sequence: 0 + } ); - viewDocument.on( 'delete', spy ); + const data = deleteSpy.args[ 0 ][ 1 ]; + const range = data.selectionToRemove.getFirstRange(); + const viewText = viewRoot.getChild( 0 ).getChild( 0 ); - viewDocument.fire( 'beforeinput', new DomEventData( viewDocument, getDomEvent(), { - domTarget: domElement, - inputType: 'deleteContentBackward', - targetRanges: [ - view.createRange( view.createPositionAt( viewRoot.getChild( 0 ), 0 ), view.createPositionAt( viewText, 1 ) ) - ] - } ) ); + expect( range.start.offset ).to.equal( 3 ); + expect( range.start.parent ).to.equal( viewText ); + expect( range.end.offset ).to.equal( 25 ); + expect( range.end.parent ).to.equal( viewText ); + } ); - expect( spy.calledOnce ).to.be.true; + it( 'should not use target ranges if it is collapsed', () => { + viewSetData( view, '

foo{}

' ); + const viewRange = view.document.selection.getFirstRange(); + const domRange = view.domConverter.viewRangeToDom( viewRange ); - const data = spy.args[ 0 ][ 1 ]; - expect( data ).to.have.property( 'selectionToRemove' ); + fireBeforeInputDomEvent( domRoot, { + inputType: 'deleteContentBackward', + ranges: [ domRange ] + } ); - const range = data.selectionToRemove.getFirstRange(); + sinon.assert.calledOnce( deleteSpy ); + sinon.assert.calledWithMatch( deleteSpy, {}, { + unit: 'codePoint', + direction: 'backward', + sequence: 0, + selectionToRemove: undefined + } ); + } ); - expect( range.start.offset ).to.equal( 0 ); - expect( range.start.parent ).to.equal( viewRoot.getChild( 0 ) ); - expect( range.end.offset ).to.equal( 1 ); - expect( range.end.parent ).to.equal( viewText ); + it( 'should not use target ranges if there is more than one range', () => { + viewSetData( view, '

foo{}

' ); + const viewRange = view.document.selection.getFirstRange(); + const domRange = view.domConverter.viewRangeToDom( viewRange ); + + fireBeforeInputDomEvent( domRoot, { + inputType: 'deleteContentBackward', + ranges: [ domRange, domRange ] } ); - it( 'should not fired be on beforeinput when event type is other than deleteContentBackward', () => { - const spy = sinon.spy(); + sinon.assert.calledOnce( deleteSpy ); + sinon.assert.calledWithMatch( deleteSpy, {}, { + unit: 'codePoint', + direction: 'backward', + sequence: 0, + selectionToRemove: undefined + } ); + } ); - viewDocument.on( 'delete', spy ); + it( 'should set selectionToRemove if target ranges spans different parent nodes', () => { + viewSetData( view, '

fo{o

]bar

' ); + const viewRange = view.document.selection.getFirstRange(); + const domRange = view.domConverter.viewRangeToDom( viewRange ); - viewDocument.fire( 'beforeinput', new DomEventData( viewDocument, getDomEvent(), { - domTarget: domElement, - inputType: 'insertText', - targetRanges: [] - } ) ); + fireBeforeInputDomEvent( domRoot, { + inputType: 'deleteContentBackward', + ranges: [ domRange ] + } ); - expect( spy.calledOnce ).to.be.false; + sinon.assert.calledOnce( deleteSpy ); + sinon.assert.calledWithMatch( deleteSpy, {}, { + unit: 'selection', + direction: 'backward', + sequence: 0 } ); - it( 'should stop the beforeinput event when delete event is stopped', () => { - const keydownSpy = sinon.spy(); - viewDocument.on( 'beforeinput', keydownSpy ); - viewDocument.on( 'delete', evt => evt.stop() ); + const data = deleteSpy.args[ 0 ][ 1 ]; + const range = data.selectionToRemove.getFirstRange(); - viewDocument.fire( 'beforeinput', new DomEventData( viewDocument, getDomEvent(), { - domTarget: domElement, - inputType: 'deleteContentBackward', - targetRanges: [] - } ) ); + expect( range.start.offset ).to.equal( 2 ); + expect( range.start.parent ).to.equal( viewRoot.getChild( 0 ).getChild( 0 ) ); + expect( range.end.offset ).to.equal( 0 ); + expect( range.end.parent ).to.equal( viewRoot.getChild( 1 ) ); + } ); + + it( 'should set selectionToRemove if target ranges spans a single character and an element', () => { + viewSetData( view, '

fo{o
]

' ); + const viewRange = view.document.selection.getFirstRange(); + const domRange = view.domConverter.viewRangeToDom( viewRange ); - sinon.assert.notCalled( keydownSpy ); + fireBeforeInputDomEvent( domRoot, { + inputType: 'deleteContentBackward', + ranges: [ domRange ] } ); - it( 'should not stop keydown event when delete event is not stopped', () => { - const keydownSpy = sinon.spy(); - viewDocument.on( 'beforeinput', keydownSpy ); - viewDocument.on( 'delete', evt => evt.stop() ); + sinon.assert.calledOnce( deleteSpy ); + sinon.assert.calledWithMatch( deleteSpy, {}, { + unit: 'selection', + direction: 'backward', + sequence: 0 + } ); - viewDocument.fire( 'beforeinput', new DomEventData( viewDocument, getDomEvent(), { - domTarget: domElement, - inputType: 'insertText', - targetRanges: [] - } ) ); + const data = deleteSpy.args[ 0 ][ 1 ]; + const range = data.selectionToRemove.getFirstRange(); - sinon.assert.calledOnce( keydownSpy ); - } ); + expect( range.start.offset ).to.equal( 2 ); + expect( range.start.parent ).to.equal( viewRoot.getChild( 0 ).getChild( 0 ) ); + expect( range.end.offset ).to.equal( 2 ); + expect( range.end.parent ).to.equal( viewRoot.getChild( 0 ) ); } ); } ); From ef2b5734b29dc624c176665781a9b158e92a1529 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Tue, 11 Apr 2023 12:58:04 +0200 Subject: [PATCH 5/5] Added tests. --- .../src/controller/editingcontroller.ts | 4 - .../tests/controller/editingcontroller.js | 257 ++++++++++++++++-- 2 files changed, 232 insertions(+), 29 deletions(-) diff --git a/packages/ckeditor5-engine/src/controller/editingcontroller.ts b/packages/ckeditor5-engine/src/controller/editingcontroller.ts index e499f294b7f..28343c619dd 100644 --- a/packages/ckeditor5-engine/src/controller/editingcontroller.ts +++ b/packages/ckeditor5-engine/src/controller/editingcontroller.ts @@ -254,10 +254,6 @@ export default class EditingController extends ObservableMixin() { */ function fixTargetRanges( mapper: Mapper, schema: Schema ): GetCallback { return ( evt, data ) => { - if ( !data.targetRanges ) { - return; - } - for ( let i = 0; i < data.targetRanges.length; i++ ) { const viewRange = data.targetRanges[ i ]; const modelRange = mapper.toModelRange( viewRange ); diff --git a/packages/ckeditor5-engine/tests/controller/editingcontroller.js b/packages/ckeditor5-engine/tests/controller/editingcontroller.js index 45d5091de89..657c8832186 100644 --- a/packages/ckeditor5-engine/tests/controller/editingcontroller.js +++ b/packages/ckeditor5-engine/tests/controller/editingcontroller.js @@ -632,7 +632,7 @@ describe( 'EditingController', () => { } ); describe( 'beforeInput target ranges fixing', () => { - let editor, model, view, viewDocument, viewRoot, beforeInputSpy, deleteSpy; + let editor, model, view, viewDocument, viewRoot, beforeInputSpy, deleteSpy, insertTextSpy, enterSpy; testUtils.createSinonSandbox(); @@ -644,13 +644,25 @@ describe( 'EditingController', () => { viewRoot = viewDocument.getRoot(); beforeInputSpy = testUtils.sinon.spy(); - viewDocument.on( 'beforeinput', beforeInputSpy ); + viewDocument.on( 'beforeinput', beforeInputSpy, { context: '$capture', priority: 'highest' } ); - deleteSpy = testUtils.sinon.spy(); - viewDocument.on( 'delete', deleteSpy ); + deleteSpy = testUtils.sinon.stub().callsFake( evt => evt.stop() ); + viewDocument.on( 'delete', deleteSpy, { context: '$capture', priority: 'highest' } ); + + insertTextSpy = testUtils.sinon.stub().callsFake( evt => evt.stop() ); + viewDocument.on( 'insertText', insertTextSpy, { context: '$capture', priority: 'highest' } ); + + enterSpy = testUtils.sinon.stub().callsFake( evt => evt.stop() ); + viewDocument.on( 'enter', enterSpy, { context: '$capture', priority: 'highest' } ); // Stub `editor.editing.view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. sinon.stub( editor.editing.view, 'scrollToTheSelection' ); + + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } ); + + model.schema.register( 'blockObject', { inheritAllFrom: '$blockObject' } ); + editor.conversion.elementToElement( { model: 'blockObject', view: 'div' } ); } ); afterEach( async () => { @@ -658,10 +670,7 @@ describe( 'EditingController', () => { } ); it( 'should not fix flat range on text', () => { - model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); - editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } ); - - setModelData( model, 'foobar[]' ); + setModelData( model, 'foobar' ); const targetRanges = [ view.createRange( view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 3 ), @@ -680,29 +689,31 @@ describe( 'EditingController', () => { const inputData = beforeInputSpy.args[ 0 ][ 1 ]; expect( inputData.inputType ).to.equal( eventData.inputType ); - expect( inputData.targetRanges ).to.deep.equal( eventData.targetRanges ); + expect( inputData.targetRanges[ 0 ] ).to.deep.equal( view.createRange( + view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 3 ), + view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 6 ) + ) ); // Verify delete event. sinon.assert.calledOnce( deleteSpy ); const deleteData = deleteSpy.args[ 0 ][ 1 ]; - - expect( deleteData.selectionToRemove.getFirstRange().isEqual( targetRanges[ 0 ] ) ).to.be.true; - expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( deleteData.selectionToRemove.getFirstRange().isEqual( view.createRange( + view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 3 ), + view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 6 ) + ) ) ).to.be.true; } ); - it.skip( 'should fix range that ends in block object', () => { - model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); - editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } ); - - model.schema.register( 'blockObject', { inheritAllFrom: '$blockObject' } ); - editor.conversion.elementToElement( { model: 'blockObject', view: 'div' } ); - - setModelData( model, '[]foobar' ); + it( 'should fix range that ends in block object (deleteContentBackward)', () => { + setModelData( model, + 'foo' + + '' + + 'bar' + ); const targetRanges = [ view.createRange( - view.createPositionAt( viewRoot.getChild( 0 ), 0 ), - view.createPositionAt( viewRoot.getChild( 1 ).getChild( 0 ), 0 ) + view.createPositionAt( viewRoot.getChild( 1 ), 0 ), + view.createPositionAt( viewRoot.getChild( 2 ).getChild( 0 ), 0 ) ) ]; const eventData = { @@ -717,15 +728,211 @@ describe( 'EditingController', () => { const inputData = beforeInputSpy.args[ 0 ][ 1 ]; expect( inputData.inputType ).to.equal( eventData.inputType ); - expect( inputData.targetRanges[ 0 ] ).to.deep.equal( targetRanges[ 0 ] ); + expect( inputData.targetRanges[ 0 ] ).to.deep.equal( view.createRange( + view.createPositionAt( viewRoot, 1 ), + view.createPositionAt( viewRoot.getChild( 2 ).getChild( 0 ), 0 ) + ) ); // Verify delete event. sinon.assert.calledOnce( deleteSpy ); const deleteData = deleteSpy.args[ 0 ][ 1 ]; + expect( deleteData.selectionToRemove.getFirstRange().isEqual( view.createRange( + view.createPositionAt( viewRoot, 1 ), + view.createPositionAt( viewRoot.getChild( 2 ).getChild( 0 ), 0 ) + ) ) ).to.be.true; + } ); + + it( 'should fix range that ends in block object (deleteContentForward)', () => { + setModelData( model, + 'foo' + + '' + + 'bar' + ); + + const targetRanges = [ view.createRange( + view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 3 ), + view.createPositionAt( viewRoot.getChild( 1 ), 0 ) + ) ]; + + const eventData = { + inputType: 'deleteContentForward', + targetRanges + }; + + fireBeforeInputEvent( eventData ); + + // Verify beforeinput range. + sinon.assert.calledOnce( beforeInputSpy ); + + const inputData = beforeInputSpy.args[ 0 ][ 1 ]; + expect( inputData.inputType ).to.equal( eventData.inputType ); + expect( inputData.targetRanges[ 0 ] ).to.deep.equal( view.createRange( + view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 3 ), + view.createPositionAt( viewRoot, 2 ) + ) ); + + // Verify delete event. + sinon.assert.calledOnce( deleteSpy ); + } ); + + it( 'should fix range that is collapsed inside an object (insertText)', () => { + setModelData( model, + 'foo' + + '' + + 'bar' + ); + + const targetRanges = [ view.createRange( + view.createPositionAt( viewRoot.getChild( 1 ), 0 ), + view.createPositionAt( viewRoot.getChild( 1 ), 0 ) + ) ]; + + const eventData = { + inputType: 'insertText', + data: 'abc', + targetRanges + }; + + fireBeforeInputEvent( eventData ); + + // Verify beforeinput range. + sinon.assert.calledOnce( beforeInputSpy ); + + const inputData = beforeInputSpy.args[ 0 ][ 1 ]; + expect( inputData.inputType ).to.equal( eventData.inputType ); + expect( inputData.data ).to.equal( eventData.data ); + expect( inputData.targetRanges[ 0 ] ).to.deep.equal( view.createRange( + view.createPositionAt( viewRoot, 1 ), + view.createPositionAt( viewRoot, 2 ) + ) ); + + // Verify insertText event. + sinon.assert.calledOnce( insertTextSpy ); + + const insertTextData = insertTextSpy.args[ 0 ][ 1 ]; + expect( insertTextData.selection.getFirstRange().isEqual( view.createRange( + view.createPositionAt( viewRoot, 1 ), + view.createPositionAt( viewRoot, 2 ) + ) ) ).to.be.true; + } ); + + it( 'should fix range that is collapsed after an object (insertText)', () => { + // Note that this is a synthetic scenario and in real life scenarios such event (insert text) + // should prefer to jump into the nearest position that accepts text (now it wraps the object). + + setModelData( model, + 'foo' + + '' + + 'bar' + ); + + const targetRanges = [ view.createRange( + view.createPositionAt( viewRoot, 2 ), + view.createPositionAt( viewRoot, 2 ) + ) ]; + + const eventData = { + inputType: 'insertText', + data: 'abc', + targetRanges + }; + + fireBeforeInputEvent( eventData ); + + // Verify beforeinput range. + sinon.assert.calledOnce( beforeInputSpy ); + + const inputData = beforeInputSpy.args[ 0 ][ 1 ]; + expect( inputData.inputType ).to.equal( eventData.inputType ); + expect( inputData.data ).to.equal( eventData.data ); + expect( inputData.targetRanges[ 0 ] ).to.deep.equal( view.createRange( + view.createPositionAt( viewRoot, 1 ), + view.createPositionAt( viewRoot, 2 ) + ) ); + + // Verify insertText event. + sinon.assert.calledOnce( insertTextSpy ); + + const insertTextData = insertTextSpy.args[ 0 ][ 1 ]; + expect( insertTextData.selection.getFirstRange().isEqual( view.createRange( + view.createPositionAt( viewRoot, 1 ), + view.createPositionAt( viewRoot, 2 ) + ) ) ).to.be.true; + } ); + + it( 'should fix range that is collapsed before an object (insertText)', () => { + setModelData( model, + 'foo' + + '' + + 'bar' + ); + + const targetRanges = [ view.createRange( + view.createPositionAt( viewRoot, 1 ), + view.createPositionAt( viewRoot, 1 ) + ) ]; + + const eventData = { + inputType: 'insertText', + data: 'abc', + targetRanges + }; + + fireBeforeInputEvent( eventData ); + + // Verify beforeinput range. + sinon.assert.calledOnce( beforeInputSpy ); + + const inputData = beforeInputSpy.args[ 0 ][ 1 ]; + expect( inputData.inputType ).to.equal( eventData.inputType ); + expect( inputData.data ).to.equal( eventData.data ); + expect( inputData.targetRanges[ 0 ] ).to.deep.equal( view.createRange( + view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 3 ), + view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 3 ) + ) ); + + // Verify insertText event. + sinon.assert.calledOnce( insertTextSpy ); + + const insertTextData = insertTextSpy.args[ 0 ][ 1 ]; + expect( insertTextData.selection.getFirstRange().isEqual( view.createRange( + view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 3 ), + view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 3 ) + ) ) ).to.be.true; + } ); + + it( 'should fix range that is wrapping the block element (enter)', () => { + setModelData( model, + 'foo' + + 'bar' + + 'baz' + ); + + const targetRanges = [ view.createRange( + view.createPositionAt( viewRoot, 1 ), + view.createPositionAt( viewRoot, 2 ) + ) ]; + + const eventData = { + inputType: 'insertParagraph', + targetRanges + }; + + fireBeforeInputEvent( eventData ); + + // Verify beforeinput range. + sinon.assert.calledOnce( beforeInputSpy ); + + const inputData = beforeInputSpy.args[ 0 ][ 1 ]; + expect( inputData.inputType ).to.equal( eventData.inputType ); + expect( inputData.targetRanges[ 0 ] ).to.deep.equal( view.createRange( + view.createPositionAt( viewRoot.getChild( 1 ).getChild( 0 ), 0 ), + view.createPositionAt( viewRoot.getChild( 1 ).getChild( 0 ), 3 ) + ) ); - expect( deleteData.selectionToRemove.getFirstRange().isEqual( targetRanges[ 0 ] ) ).to.be.true; - expect( getModelData( model ) ).to.equal( 'foo[]' ); + // Verify enter event. + sinon.assert.calledOnce( enterSpy ); } ); function fireBeforeInputEvent( eventData ) {