Skip to content

Commit

Permalink
Merge pull request #12795 from ckeditor/ck/2045
Browse files Browse the repository at this point in the history
Feature (engine): Improved support for dictation (via VoiceControl) on iOS and multi-line text replacements on macOS. Closes #2045. Closes #11443.
  • Loading branch information
Reinmar authored Nov 16, 2022
2 parents ddfd938 + 2c85517 commit 1d7def3
Show file tree
Hide file tree
Showing 2 changed files with 187 additions and 0 deletions.
43 changes: 43 additions & 0 deletions packages/ckeditor5-engine/src/view/observer/inputobserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,49 @@ export default class InputObserver extends DomEventObserver<'beforeinput'> {
return;
}

// Normalize the insertText data that includes new-line characters.
// https://github.com/ckeditor/ckeditor5/issues/2045.
if ( domEvent.inputType == 'insertText' && data && data.includes( '\n' ) ) {
// There might be a single new-line or double for new paragraph, but we translate
// it to paragraphs as it is our default action for enter handling.
const parts = data.split( /\n{1,2}/g );

let partTargetRanges = targetRanges;

for ( let i = 0; i < parts.length; i++ ) {
const dataPart = parts[ i ];

if ( dataPart != '' ) {
this.fire( domEvent.type, domEvent, {
data: dataPart,
dataTransfer,
targetRanges: partTargetRanges,
inputType: domEvent.inputType,
isComposing: domEvent.isComposing
} );

// Use the result view selection so following events will be added one after another.
partTargetRanges = [ viewDocument.selection.getFirstRange()! ];
}

if ( i + 1 < parts.length ) {
this.fire( domEvent.type, domEvent, {
inputType: 'insertParagraph',
targetRanges: partTargetRanges
} );

// Use the result view selection so following events will be added one after another.
partTargetRanges = [ viewDocument.selection.getFirstRange()! ];
}
}

// @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
// @if CK_DEBUG_TYPING // console.groupEnd();
// @if CK_DEBUG_TYPING // }

return;
}

// Fire the normalized beforeInput event.
this.fire( domEvent.type, domEvent, {
data,
Expand Down
144 changes: 144 additions & 0 deletions packages/ckeditor5-engine/tests/view/observer/inputobserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,150 @@ describe( 'InputObserver', () => {
) ) ).to.be.true;
} );

describe( 'should split single insertText with new-line characters into separate events', () => {
it( 'single new-line surrounded by text', () => {
const domRange = global.document.createRange();

domRange.setStart( domEditable.firstChild.firstChild, 0 );
domRange.setEnd( domEditable.firstChild.firstChild, 0 );

// Mocking view selection and offsets since there is no change in the model and view in this tests.
let i = 0;

sinon.stub( viewDocument.selection, 'getFirstRange' ).callsFake( () => {
return view.createRange(
view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), i++ )
);
} );

fireMockNativeBeforeInput( {
inputType: 'insertText',
data: 'foo\nbar',
getTargetRanges: () => [ domRange ]
} );

expect( beforeInputSpy.callCount ).to.equal( 3 );

const firstCallData = beforeInputSpy.getCall( 0 ).args[ 1 ];
const secondCallData = beforeInputSpy.getCall( 1 ).args[ 1 ];
const thirdCallData = beforeInputSpy.getCall( 2 ).args[ 1 ];

expect( firstCallData.inputType ).to.equal( 'insertText' );
expect( firstCallData.data ).to.equal( 'foo' );
expect( firstCallData.targetRanges[ 0 ].isEqual( view.createRange(
view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 0 )
) ) ).to.be.true;

expect( secondCallData.inputType ).to.equal( 'insertParagraph' );
expect( secondCallData.targetRanges[ 0 ].isEqual( view.createRange(
view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 1 )
) ) ).to.be.true;

expect( thirdCallData.inputType ).to.equal( 'insertText' );
expect( thirdCallData.data ).to.equal( 'bar' );
expect( thirdCallData.targetRanges[ 0 ].isEqual( view.createRange(
view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 3 )
) ) ).to.be.true;
} );

it( 'new-line after a text', () => {
const domRange = global.document.createRange();

domRange.setStart( domEditable.firstChild.firstChild, 0 );
domRange.setEnd( domEditable.firstChild.firstChild, 0 );

sinon.stub( viewDocument.selection, 'getFirstRange' ).callsFake( () => {
return view.createRange(
view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 0 )
);
} );

fireMockNativeBeforeInput( {
inputType: 'insertText',
data: 'foo\n',
getTargetRanges: () => [ domRange ]
} );

expect( beforeInputSpy.callCount ).to.equal( 2 );

const firstCallData = beforeInputSpy.getCall( 0 ).args[ 1 ];
const secondCallData = beforeInputSpy.getCall( 1 ).args[ 1 ];

expect( firstCallData.inputType ).to.equal( 'insertText' );
expect( firstCallData.data ).to.equal( 'foo' );

expect( secondCallData.inputType ).to.equal( 'insertParagraph' );
} );

it( 'double new-line surrounded by text', () => {
const domRange = global.document.createRange();

domRange.setStart( domEditable.firstChild.firstChild, 0 );
domRange.setEnd( domEditable.firstChild.firstChild, 0 );

sinon.stub( viewDocument.selection, 'getFirstRange' ).callsFake( () => {
return view.createRange(
view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 0 )
);
} );

fireMockNativeBeforeInput( {
inputType: 'insertText',
data: 'foo\n\nbar',
getTargetRanges: () => [ domRange ]
} );

expect( beforeInputSpy.callCount ).to.equal( 3 );

const firstCallData = beforeInputSpy.getCall( 0 ).args[ 1 ];
const secondCallData = beforeInputSpy.getCall( 1 ).args[ 1 ];
const thirdCallData = beforeInputSpy.getCall( 2 ).args[ 1 ];

expect( firstCallData.inputType ).to.equal( 'insertText' );
expect( firstCallData.data ).to.equal( 'foo' );

expect( secondCallData.inputType ).to.equal( 'insertParagraph' );

expect( thirdCallData.inputType ).to.equal( 'insertText' );
expect( thirdCallData.data ).to.equal( 'bar' );
} );

it( 'tripple new-line surrounded by text', () => {
const domRange = global.document.createRange();

domRange.setStart( domEditable.firstChild.firstChild, 0 );
domRange.setEnd( domEditable.firstChild.firstChild, 0 );

sinon.stub( viewDocument.selection, 'getFirstRange' ).callsFake( () => {
return view.createRange(
view.createPositionAt( viewRoot.getChild( 0 ).getChild( 0 ), 0 )
);
} );

fireMockNativeBeforeInput( {
inputType: 'insertText',
data: 'foo\n\n\nbar',
getTargetRanges: () => [ domRange ]
} );

expect( beforeInputSpy.callCount ).to.equal( 4 );

const firstCallData = beforeInputSpy.getCall( 0 ).args[ 1 ];
const secondCallData = beforeInputSpy.getCall( 1 ).args[ 1 ];
const thirdCallData = beforeInputSpy.getCall( 2 ).args[ 1 ];
const fourthCallData = beforeInputSpy.getCall( 3 ).args[ 1 ];

expect( firstCallData.inputType ).to.equal( 'insertText' );
expect( firstCallData.data ).to.equal( 'foo' );

expect( secondCallData.inputType ).to.equal( 'insertParagraph' );
expect( thirdCallData.inputType ).to.equal( 'insertParagraph' );

expect( fourthCallData.inputType ).to.equal( 'insertText' );
expect( fourthCallData.data ).to.equal( 'bar' );
} );
} );

function fireMockNativeBeforeInput( domEvtMock ) {
observer.onDomEvent( Object.assign( {
type: 'beforeinput',
Expand Down

0 comments on commit 1d7def3

Please sign in to comment.