/** * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import List from '@ckeditor/ckeditor5-list/src/list'; import Link from '@ckeditor/ckeditor5-link/src/link'; import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic'; import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter'; import Input from '../src/input'; import TextTransformation from '../src/texttransformation'; import Writer from '@ckeditor/ckeditor5-engine/src/model/writer'; import ViewText from '@ckeditor/ckeditor5-engine/src/view/text'; import ViewElement from '@ckeditor/ckeditor5-engine/src/view/element'; import ViewContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement'; import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; import env from '@ckeditor/ckeditor5-utils/src/env'; /* global document */ describe( 'Input feature', () => { let editor, model, modelRoot, view, viewDocument, viewRoot, listenter, domElement; testUtils.createSinonSandbox(); beforeEach( () => { listenter = Object.create( EmitterMixin ); domElement = document.createElement( 'div' ); document.body.appendChild( domElement ); const options = { plugins: [ Input, Paragraph, Bold, Italic, List, ShiftEnter, Link, TextTransformation ], typing: { transformations: { extra: [ { from: ':)', to: 'π' }, { from: ':family:', to: 'π¨βπ©βπ§' } ] } } }; return ClassicTestEditor.create( domElement, options ) .then( newEditor => { // Mock image feature. newEditor.model.schema.register( 'image', { allowWhere: '$text' } ); newEditor.conversion.elementToElement( { model: 'image', view: 'img' } ); editor = newEditor; model = editor.model; modelRoot = model.document.getRoot(); view = editor.editing.view; viewDocument = view.document; viewRoot = viewDocument.getRoot(); editor.setData( '
foobar
' ); model.change( writer => { writer.setSelection( modelRoot.getChild( 0 ), 3 ); } ); } ); } ); afterEach( () => { listenter.stopListening(); domElement.remove(); return editor.destroy(); } ); describe( 'isInput()', () => { let input; beforeEach( () => { input = editor.plugins.get( 'Input' ); } ); it( 'returns true for batch created using "input" command', done => { model.document.once( 'change:data', ( evt, batch ) => { expect( input.isInput( batch ) ).to.be.true; done(); } ); editor.execute( 'input', { text: 'foo' } ); } ); it( 'returns false for batch not created using "input" command', () => { const batch = model.createBatch(); expect( input.isInput( batch ) ).to.be.false; } ); } ); describe( 'mutations handling', () => { it( 'should handle text mutation', () => { viewDocument.fire( 'mutations', [ { type: 'text', oldText: 'foobar', newText: 'fooxbar', node: viewRoot.getChild( 0 ).getChild( 0 ) } ] ); expect( getModelData( model ) ).to.equal( 'foox{}bar
' ); } ); it( 'should handle text mutation change', () => { viewDocument.fire( 'mutations', [ { type: 'text', oldText: 'foobar', newText: 'foodar', node: viewRoot.getChild( 0 ).getChild( 0 ) } ] ); expect( getModelData( model ) ).to.equal( 'food{}ar
' ); } ); it( 'should handle text node insertion', () => { editor.setData( '' ); viewDocument.fire( 'mutations', [ { type: 'children', oldChildren: [], newChildren: [ new ViewText( viewDocument, 'x' ) ], node: viewRoot.getChild( 0 ) } ] ); expect( getModelData( model ) ).to.equal( 'x{}
' ); } ); it( 'should apply selection attributes to the inserted text', () => { setModelData( model, 'foobar
' ); viewDocument.fire( 'mutations', [ { type: 'text', oldText: 'foo', newText: 'foob', node: viewRoot.getChild( 0 ).getChild( 0 ) }, { type: 'text', oldText: 'bar', newText: 'ar', node: viewRoot.getChild( 0 ).getChild( 1 ).getChild( 0 ) } ] ); expect( getModelData( model ) ).to.equal( 'foob{}ar
' ); } ); it( 'should handle multiple text node insertion', () => { editor.setData( '' ); viewDocument.fire( 'mutations', [ { type: 'children', oldChildren: [], newChildren: [ new ViewText( viewDocument, 'x' ) ], node: viewRoot.getChild( 0 ) }, { type: 'children', oldChildren: [], newChildren: [ new ViewText( viewDocument, 'y' ) ], node: viewRoot.getChild( 1 ) } ] ); expect( getModelData( model ) ).to.equal( 'x
y{}
' ); } ); it( 'should do nothing when two nodes were inserted', () => { editor.setData( '' ); viewDocument.fire( 'mutations', [ { type: 'children', oldChildren: [], newChildren: [ new ViewText( viewDocument, 'x' ), new ViewElement( viewDocument, 'img' ) ], node: viewRoot.getChild( 0 ) } ] ); expect( getModelData( model ) ).to.equal( '[]
' ); } ); it( 'should do nothing when two nodes were inserted and one removed', () => { viewDocument.fire( 'mutations', [ { type: 'children', oldChildren: [ new ViewText( viewDocument, 'foobar' ) ], newChildren: [ new ViewText( viewDocument, 'x' ), new ViewElement( viewDocument, 'img' ) ], node: viewRoot.getChild( 0 ) } ] ); expect( getModelData( model ) ).to.equal( 'foo{}bar
' ); } ); it( 'should handle multiple children in the node', () => { editor.setData( 'foo
foox{}
foo{}bar
' ); } ); it( 'should do nothing when element was inserted', () => { editor.setData( '' ); viewDocument.fire( 'mutations', [ { type: 'children', oldChildren: [], newChildren: [ new ViewElement( viewDocument, 'img' ) ], node: viewRoot.getChild( 0 ) } ] ); expect( getModelData( model ) ).to.equal( '[]
' ); } ); it( 'should set model selection appropriately to view selection passed in mutations event', () => { // This test case emulates spellchecker correction. const viewSelection = view.createSelection(); viewSelection.setTo( viewRoot.getChild( 0 ).getChild( 0 ), 6 ); viewDocument.fire( 'mutations', [ { type: 'text', oldText: 'foobar', newText: 'foodar', node: viewRoot.getChild( 0 ).getChild( 0 ) } ], viewSelection ); expect( getModelData( model ) ).to.equal( 'foodar{}
' ); } ); it( 'should use up to one insert and remove operations (spellchecker)', () => { // This test case emulates spellchecker correction. const viewSelection = view.createSelection(); viewSelection.setTo( viewRoot.getChild( 0 ).getChild( 0 ), 6 ); testUtils.sinon.spy( Writer.prototype, 'insert' ); testUtils.sinon.spy( Writer.prototype, 'remove' ); viewDocument.fire( 'mutations', [ { type: 'text', oldText: 'foobar', newText: 'fxobxr', node: viewRoot.getChild( 0 ).getChild( 0 ) } ], viewSelection ); expect( Writer.prototype.insert.calledOnce ).to.be.true; expect( Writer.prototype.remove.calledOnce ).to.be.true; } ); it( 'should place selection after when correcting to longer word (spellchecker)', () => { // This test case emulates spellchecker correction. editor.setData( 'Foo hous a
' ); const viewSelection = view.createSelection(); viewSelection.setTo( viewRoot.getChild( 0 ).getChild( 0 ), 9 ); viewDocument.fire( 'mutations', [ { type: 'text', oldText: 'Foo hous a', newText: 'Foo house a', node: viewRoot.getChild( 0 ).getChild( 0 ) } ], viewSelection ); expect( getModelData( model ) ).to.equal( 'Foo house{} a
' ); } ); it( 'should place selection after when correcting to shorter word (spellchecker)', () => { // This test case emulates spellchecker correction. editor.setData( 'Bar athat foo
' ); const viewSelection = view.createSelection(); viewSelection.setTo( viewRoot.getChild( 0 ).getChild( 0 ), 8 ); viewDocument.fire( 'mutations', [ { type: 'text', oldText: 'Bar athat foo', newText: 'Bar that foo', node: viewRoot.getChild( 0 ).getChild( 0 ) } ], viewSelection ); expect( getModelData( model ) ).to.equal( 'Bar that{} foo
' ); } ); it( 'should place selection after when merging two words (spellchecker)', () => { // This test case emulates spellchecker correction. editor.setData( 'Foo hous e
' ); const viewSelection = view.createSelection(); viewSelection.setTo( viewRoot.getChild( 0 ).getChild( 0 ), 9 ); viewDocument.fire( 'mutations', [ { type: 'text', oldText: 'Foo hous e', newText: 'Foo house', node: viewRoot.getChild( 0 ).getChild( 0 ) } ], viewSelection ); expect( getModelData( model ) ).to.equal( 'Foo house{}
' ); } ); it( 'should place non-collapsed selection after changing single character (composition)', () => { editor.setData( 'Foo house
' ); const viewSelection = view.createSelection(); viewSelection.setTo( viewRoot.getChild( 0 ).getChild( 0 ), 8 ); viewSelection.setFocus( viewRoot.getChild( 0 ).getChild( 0 ), 9 ); viewDocument.fire( 'mutations', [ { type: 'text', oldText: 'Foo house', newText: 'Foo housa', node: viewRoot.getChild( 0 ).getChild( 0 ) } ], viewSelection ); expect( getModelData( model ) ).to.equal( 'Foo hous{a}
' ); } ); it( 'should replace last with space', () => { model.change( writer => { writer.setSelection( modelRoot.getChild( 0 ), 6 ); } ); viewDocument.fire( 'mutations', [ { type: 'text', oldText: 'foobar', newText: 'foobar\u00A0', node: viewRoot.getChild( 0 ).getChild( 0 ) } ] ); expect( getModelData( model ) ).to.equal( 'foobar {}
' ); } ); it( 'should replace first with space', () => { model.change( writer => { writer.setSelection( writer.createRange( writer.createPositionAt( modelRoot.getChild( 0 ), 0 ), writer.createPositionAt( modelRoot.getChild( 0 ), 0 ) ) ); } ); viewDocument.fire( 'mutations', [ { type: 'text', oldText: 'foobar', newText: '\u00A0foobar', node: viewRoot.getChild( 0 ).getChild( 0 ) } ] ); expect( getModelData( model ) ).to.equal( '{}foobar
' ); } ); it( 'should replace all with spaces', () => { model.change( writer => { writer.setSelection( modelRoot.getChild( 0 ), 6 ); } ); viewDocument.fire( 'mutations', [ { type: 'text', oldText: 'foobar', newText: 'foobar\u00A0\u00A0\u00A0baz', node: viewRoot.getChild( 0 ).getChild( 0 ) } ] ); expect( getModelData( model ) ).to.equal( 'foobar baz{}
' ); } ); // ckeditor5#718. it( 'should not crash and prevent all changes if view common ancestor of mutations cannot be mapped to model', () => { editor.setData( 'Foo
{}Foo
Foo
' ); editor.model.change( writer => { writer.setSelection( editor.model.document.getRoot().getChild( 0 ), 'end' ); writer.removeSelectionAttribute( 'bold' ); } ); // We need to change the DOM content manually because typing algorithm actually does not check // `newChildren` and `oldChildren` list but takes them from DOM and model. const p = viewRoot.getChild( 0 ); const domP = editor.editing.view.domConverter.mapViewToDom( p ); domP.appendChild( document.createTextNode( ' ' ) ); domP.appendChild( document.createElement( 'br' ) ); viewDocument.fire( 'mutations', [ { type: 'children', oldChildren: [ viewRoot.getChild( 0 ).getChild( 0 ) ], newChildren: [ new ViewElement( viewDocument, 'strong', null, new ViewText( viewDocument, 'Foo' ) ), new ViewText( viewDocument, ' ' ), new ViewElement( viewDocument, 'br' ) ], node: viewRoot.getChild( 0 ) } ] ); expect( getViewData( view ) ).to.equal( 'Foo {}
' ); } ); it( 'should handle children mutation correctly if there are soft breaks in the mutated container', () => { editor.setData( 'Foo
Bar
Foo
Bar {}
Foo bar
' ); model.change( writer => { writer.setSelection( modelRoot.getChild( 0 ), 1 ); } ); // We need to change the DOM content manually because typing algorithm actually does not check // `newChildren` and `oldChildren` list but takes them from DOM and model. const strong = viewRoot.getChild( 0 ).getChild( 0 ).getChild( 0 ); const domStrong = editor.editing.view.domConverter.mapViewToDom( strong ); domStrong.appendChild( document.createTextNode( 'x' ) ); // The mutation provided here is a bit different than what browser outputs, but browser outputs three mutations // which changes the order of elements in the DOM so to keep it simple, only one, key mutation is used in the test. // Also, the only thing that the typing algorithm takes from the mutations is `node`... viewDocument.fire( 'mutations', [ { type: 'children', oldChildren: Array.from( viewRoot.getChild( 0 ).getChild( 0 ).getChildren() ), newChildren: [ new ViewElement( viewDocument, 'strong', null, new ViewText( viewDocument, 'Fx' ) ) ], node: viewRoot.getChild( 0 ).getChild( 0 ) } ] ); expect( getModelData( model ) ).to.equal( '' + 'Fx{}' + 'oo\u00A0bar' + '
' ); } ); it( 'should handle correctly if last element is inline', () => { editor.model.schema.register( 'placeholder', { allowWhere: '$text', isInline: true } ); editor.conversion.elementToElement( { model: 'placeholder', view: 'placeholder' } ); editor.setData( 'foo
foo
foo
foo
foo
Foo Bar
' ); const p = viewRoot.getChild( 0 ); viewDocument.fire( 'mutations', [ { type: 'text', oldText: ' ', newText: ' ', node: p.getChild( 1 ) }, { type: 'text', oldText: 'Foo', newText: 'Foox', node: p.getChild( 0 ) } ] ); expect( getViewData( view ) ).to.equal( 'Foox{} Bar
' ); } ); describe( 'emoji', () => { it( 'should properly handle input after multi-byte emoji transformation (in short paragraph)', () => { testEmojiMutations( 'foo', ':)123456', 'π123456' ); } ); it( 'should properly handle input after multi-byte emoji transformation (in long paragraph)', () => { testEmojiMutations( createTestString( 200 ), ':)123456', 'π123456' ); } ); it( 'should properly handle input in text with multi-byte emoji (in short paragraph)', () => { testEmojiMutations( 'fooπ±bar', '123456' ); } ); it( 'should properly handle input in text with multi-byte emoji (in long paragraph)', () => { const textInParagraph = createTestString( 200, ( v, idx ) => idx == 60 ? 'π±' : v ); testEmojiMutations( textInParagraph, '123456' ); } ); it( 'should properly handle input in middle of text with multi-byte emoji (in short paragraph)', () => { testEmojiMutations( 'fooπ±bar', '123456', 6 ); } ); it( 'should properly handle input in middle of text with multi-byte emoji (in long paragraph)', () => { const textInParagraph = createTestString( 200, ( v, idx ) => idx == 60 ? 'π±' : v ); testEmojiMutations( textInParagraph, '123456', 100 ); } ); it( 'should properly handle input after multi-byte emoji zwj sequence transformation (in short paragraph)', () => { testEmojiMutations( 'foobar', ':family:123456', 'π¨βπ©βπ§123456' ); } ); it( 'should properly handle input after multi-byte emoji zwj sequence transformation (in long paragraph)', () => { testEmojiMutations( createTestString( 200 ), ':family:123456', 'π¨βπ©βπ§123456' ); } ); function createTestString( length, callback = v => v ) { const firstCharCode = 'a'.charCodeAt( 0 ); const lettersCount = 'z'.charCodeAt( 0 ) - firstCharCode + 1; return new Array( length ) .fill( '' ) .map( ( value, idx ) => callback( String.fromCharCode( firstCharCode + idx % lettersCount ), idx ) ) .join( '' ); } function testEmojiMutations( textInParagraph, typing, expectedOrIndex = typing, index = -1 ) { let expected = expectedOrIndex; if ( typeof expectedOrIndex == 'number' ) { index = expectedOrIndex; expected = typing; } setModelData( model, `foy{}ar
' ); } ); it( 'should do nothing on arrow key', () => { model.change( writer => { writer.setSelection( writer.createRange( writer.createPositionAt( modelRoot.getChild( 0 ), 2 ), writer.createPositionAt( modelRoot.getChild( 0 ), 4 ) ) ); } ); viewDocument.fire( 'keydown', { keyCode: getCode( 'arrowdown' ) } ); expect( getModelData( model ) ).to.equal( 'foo bar
' ); model.change( writer => { writer.setSelection( writer.createRange( writer.createPositionAt( modelRoot.getChild( 0 ), 2 ), writer.createPositionAt( modelRoot.getChild( 0 ), 5 ) ) ); } ); viewDocument.fire( 'compositionstart' ); expect( getModelData( model ) ).to.equal( 'foo bar
' ); const documentSelection = model.document.selection; // Create empty selection. model.document.selection = model.createSelection(); viewDocument.fire( 'compositionstart' ); expect( getModelData( model ) ).to.equal( 'foo
bar
' ); model.change( writer => { writer.setSelection( writer.createRange( writer.createPositionAt( modelRoot.getChild( 0 ), 2 ), writer.createPositionAt( modelRoot.getChild( 1 ), 2 ) ) ); } ); viewDocument.fire( 'compositionstart' ); expect( getModelData( model ) ).to.equal( 'foo
bar
' ); model.change( writer => { writer.setSelection( writer.createRange( writer.createPositionAt( modelRoot.getChild( 0 ), 2 ), writer.createPositionAt( modelRoot.getChild( 1 ), 2 ) ) ); } ); viewDocument.fire( 'compositionend' ); viewDocument.fire( 'keydown', { keyCode: 229 } ); expect( getModelData( model ) ).to.equal( 'foo
bar
' ); model.change( writer => { writer.setSelection( writer.createRange( writer.createPositionAt( modelRoot.getChild( 0 ), 2 ), writer.createPositionAt( modelRoot.getChild( 1 ), 2 ) ) ); } ); viewDocument.fire( 'compositionend' ); model.change( writer => { writer.setSelection( writer.createRange( writer.createPositionAt( modelRoot.getChild( 0 ), 2 ), writer.createPositionAt( modelRoot.getChild( 1 ), 1 ) ) ); } ); viewDocument.fire( 'keydown', { keyCode: 229 } ); expect( getModelData( model ) ).to.equal( 'foobar
' ); model.change( writer => { writer.setSelection( modelRoot.getChild( 0 ), 3 ); } ); } ); } ); afterEach( () => { listenter.stopListening(); domElement.remove(); return editor.destroy(); } ); after( () => { env.isAndroid = oldEnvIsAndroid; } ); describe( 'keystroke handling', () => { it( 'should remove contents', () => { model.change( writer => { writer.setSelection( writer.createRange( writer.createPositionAt( modelRoot.getChild( 0 ), 2 ), writer.createPositionAt( modelRoot.getChild( 0 ), 4 ) ) ); } ); listenter.listenTo( viewDocument, 'beforeinput', () => { expect( getModelData( model ) ).to.equal( '