diff --git a/src/input.js b/src/input.js index 1b54448..2899b4f 100644 --- a/src/input.js +++ b/src/input.js @@ -8,18 +8,18 @@ */ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; -import ChangeBuffer from './changebuffer'; import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range'; import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position'; import ViewText from '@ckeditor/ckeditor5-engine/src/view/text'; import diff from '@ckeditor/ckeditor5-utils/src/diff'; import diffToChanges from '@ckeditor/ckeditor5-utils/src/difftochanges'; import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import InputCommand from './inputcommand'; /** * Handles text input coming from the keyboard or other input methods. * - * @extends core.Plugin + * @extends module:core/plugin~Plugin */ export default class Input extends Plugin { /** @@ -28,19 +28,14 @@ export default class Input extends Plugin { init() { const editor = this.editor; const editingView = editor.editing.view; - - /** - * Typing's change buffer used to group subsequent changes into batches. - * - * @protected - * @member {typing.ChangeBuffer} #_buffer - */ - this._buffer = new ChangeBuffer( editor.document, editor.config.get( 'typing.undoStep' ) || 20 ); + const inputCommand = new InputCommand( editor, editor.config.get( 'typing.undoStep' ) || 20 ); // TODO The above default configuration value should be defined using editor.config.define() once it's fixed. + editor.commands.set( 'input', inputCommand ); + this.listenTo( editingView, 'keydown', ( evt, data ) => { - this._handleKeydown( data ); + this._handleKeydown( data, inputCommand.buffer ); }, { priority: 'lowest' } ); this.listenTo( editingView, 'mutations', ( evt, mutations, viewSelection ) => { @@ -48,16 +43,6 @@ export default class Input extends Plugin { } ); } - /** - * @inheritDoc - */ - destroy() { - super.destroy(); - - this._buffer.destroy(); - this._buffer = null; - } - /** * Handles the keydown event. We need to guess whether such keystroke is going to result * in typing. If so, then before character insertion happens, any selected content needs @@ -72,8 +57,9 @@ export default class Input extends Plugin { * * @private * @param {module:engine/view/observer/keyobserver~KeyEventData} evtData + * @param {module:typing/changebuffer~ChangeBuffer} buffer */ - _handleKeydown( evtData ) { + _handleKeydown( evtData, buffer ) { const doc = this.editor.document; if ( isSafeKeystroke( evtData ) || doc.selection.isCollapsed ) { @@ -81,20 +67,19 @@ export default class Input extends Plugin { } doc.enqueueChanges( () => { - this.editor.data.deleteContent( doc.selection, this._buffer.batch ); + this.editor.data.deleteContent( doc.selection, buffer.batch ); } ); } /** * Handles DOM mutations. * - * @param {Array.} mutations + * @private + * @param {Array.} mutations + * @param {module:engine/view/selection~Selection|null} viewSelection */ _handleMutations( mutations, viewSelection ) { - const doc = this.editor.document; - const handler = new MutationHandler( this.editor.editing, this._buffer ); - - doc.enqueueChanges( () => handler.handle( mutations, viewSelection ) ); + new MutationHandler( this.editor ).handle( mutations, viewSelection ); } } @@ -107,36 +92,32 @@ class MutationHandler { /** * Creates an instance of the mutation handler. * - * @param {module:engine/controller/editingcontroller~EditingController} editing - * @param {module:typing/changebuffer~ChangeBuffer} buffer + * @param {module:core/editor/editor~Editor} editor */ - constructor( editing, buffer ) { + constructor( editor ) { /** - * The editing controller. + * Editor instance for which mutations are handled. * - * @member {engine.controller.EditingController} #editing + * @readonly + * @member {module:core/editor/editor~Editor} #editor */ - this.editing = editing; + this.editor = editor; /** - * The change buffer. - * - * @member {engine.controller.EditingController} #buffer - */ - this.buffer = buffer; - - /** - * The number of inserted characters which need to be fed to the {@link #buffer change buffer}. + * The editing controller. * - * @member {Number} #insertedCharacterCount + * @readonly + * @member {module:engine/controller/editingcontroller~EditingController} #editing */ - this.insertedCharacterCount = 0; + this.editing = this.editor.editing; } /** * Handles given mutations. * - * @param {Array.} mutations + * @param {Array.} mutations + * @param {module:engine/view/selection~Selection|null} viewSelection */ handle( mutations, viewSelection ) { for ( let mutation of mutations ) { @@ -144,8 +125,6 @@ class MutationHandler { this._handleTextMutation( mutation, viewSelection ); this._handleTextNodeInsertion( mutation ); } - - this.buffer.input( Math.max( this.insertedCharacterCount, 0 ) ); } _handleTextMutation( mutation, viewSelection ) { @@ -210,21 +189,14 @@ class MutationHandler { // Get the position in view and model where the changes will happen. const viewPos = new ViewPosition( mutation.node, firstChangeAt ); const modelPos = this.editing.mapper.toModelPosition( viewPos ); + const removeRange = ModelRange.createFromPositionAndShift( modelPos, deletions ); + const insertText = newText.substr( firstChangeAt, insertions ); - // Remove appropriate number of characters from the model text node. - if ( deletions > 0 ) { - const removeRange = ModelRange.createFromPositionAndShift( modelPos, deletions ); - this._remove( removeRange, deletions ); - } - - // Insert appropriate characters basing on `mutation.text`. - const insertedText = newText.substr( firstChangeAt, insertions ); - this._insert( modelPos, insertedText ); - - // If there was `viewSelection` and it got correctly mapped, collapse selection at found model position. - if ( modelSelectionPosition ) { - this.editing.model.selection.collapse( modelSelectionPosition ); - } + this.editor.execute( 'input', { + text: insertText, + range: removeRange, + resultPosition: modelSelectionPosition + } ); } _handleTextNodeInsertion( mutation ) { @@ -255,29 +227,16 @@ class MutationHandler { const viewPos = new ViewPosition( mutation.node, change.index ); const modelPos = this.editing.mapper.toModelPosition( viewPos ); - let insertedText = change.values[ 0 ].data; - - // Replace   inserted by the browser with normal space. - // See comment in `_handleTextMutation`. - // In this case we don't need to do this before `diff` because we diff whole nodes. - // Just change   in case there are some. - insertedText = insertedText.replace( /\u00A0/g, ' ' ); - - this._insert( modelPos, insertedText ); - - this.editing.model.selection.collapse( modelPos.getShiftedBy( insertedText.length ) ); - } - - _insert( position, text ) { - this.buffer.batch.weakInsert( position, text ); - - this.insertedCharacterCount += text.length; - } - - _remove( range, length ) { - this.buffer.batch.remove( range ); - - this.insertedCharacterCount -= length; + const insertedText = change.values[ 0 ].data; + + this.editor.execute( 'input', { + // Replace   inserted by the browser with normal space. + // See comment in `_handleTextMutation`. + // In this case we don't need to do this before `diff` because we diff whole nodes. + // Just change   in case there are some. + text: insertedText.replace( /\u00A0/g, ' ' ), + range: new ModelRange( modelPos ) + } ); } } diff --git a/src/inputcommand.js b/src/inputcommand.js new file mode 100644 index 0000000..a4b6815 --- /dev/null +++ b/src/inputcommand.js @@ -0,0 +1,97 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module typing/inputcommand + */ + +import Command from '@ckeditor/ckeditor5-core/src/command/command'; +import ChangeBuffer from './changebuffer'; + +/** + * The input command. Used by the {@link module:typing/input~Input input feature} to handle typing. + * + * @extends module:core/command/command~Command + */ +export default class InputCommand extends Command { + /** + * Creates an instance of the command. + * + * @param {module:core/editor/editor~Editor} editor + * @param {Number} undoStepSize The maximum number of atomic changes + * which can be contained in one batch in the command buffer. + */ + constructor( editor, undoStepSize ) { + super( editor ); + + /** + * Typing's change buffer used to group subsequent changes into batches. + * + * @readonly + * @private + * @member {module:typing/changebuffer~ChangeBuffer} #_buffer + */ + this._buffer = new ChangeBuffer( editor.document, undoStepSize ); + } + + /** + * @inheritDoc + */ + destroy() { + super.destroy(); + + this._buffer.destroy(); + this._buffer = null; + } + + /** + * The current change buffer. + * + * @type {module:typing/changebuffer~ChangeBuffer} + */ + get buffer() { + return this._buffer; + } + + /** + * Executes the input command. It replaces the content within the given range with the given text. + * Replacing is a two step process, first content within the range is removed and then new text is inserted + * on the beginning of the range (which after removal is a collapsed range). + * + * @param {Object} [options] The command options. + * @param {String} [options.text=''] Text to be inserted. + * @param {module:engine/model/range~Range} [options.range] Range in which the text is inserted. Defaults + * to the first range in the current selection. + * @param {module:engine/model/position~Position} [options.resultPosition] Position at which the selection + * should be placed after the insertion. If not specified, the selection will be placed right after + * the inserted text. + */ + _doExecute( options = {} ) { + const doc = this.editor.document; + const text = options.text || ''; + const textInsertions = text.length; + const range = options.range || doc.selection.getFirstRange(); + const resultPosition = options.resultPosition; + + doc.enqueueChanges( () => { + const isCollapsedRange = range.isCollapsed; + + if ( !isCollapsedRange ) { + this._buffer.batch.remove( range ); + } + + this._buffer.batch.weakInsert( range.start, text ); + + if ( resultPosition ) { + this.editor.data.model.selection.collapse( resultPosition ); + } else if ( isCollapsedRange ) { + // If range was collapsed just shift the selection by the number of inserted characters. + this.editor.data.model.selection.collapse( range.start.getShiftedBy( textInsertions ) ); + } + + this._buffer.input( textInsertions ); + } ); + } +} diff --git a/tests/input.js b/tests/input.js index e50d955..0f294a5 100644 --- a/tests/input.js +++ b/tests/input.js @@ -4,8 +4,9 @@ */ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; -import Input from '../src/input'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Input from '../src/input'; import Batch from '@ckeditor/ckeditor5-engine/src/model/batch'; import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range'; @@ -25,6 +26,8 @@ import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils describe( 'Input feature', () => { let editor, model, modelRoot, view, viewRoot, listenter; + testUtils.createSinonSandbox(); + before( () => { listenter = Object.create( EmitterMixin ); @@ -65,22 +68,6 @@ describe( 'Input feature', () => { listenter.stopListening(); } ); - it( 'has a buffer configured to default value of config.typing.undoStep', () => { - expect( editor.plugins.get( Input )._buffer ).to.have.property( 'limit', 20 ); - } ); - - it( 'has a buffer configured to config.typing.undoStep', () => { - return VirtualTestEditor.create( { - plugins: [ Input ], - typing: { - undoStep: 5 - } - } ) - .then( editor => { - expect( editor.plugins.get( Input )._buffer ).to.have.property( 'limit', 5 ); - } ); - } ); - describe( 'mutations handling', () => { it( 'should handle text mutation', () => { view.fire( 'mutations', [ @@ -222,14 +209,14 @@ describe( 'Input feature', () => { expect( getViewData( view ) ).to.equal( '

foodar{}

' ); } ); - it( 'should use up to one insert and remove operations', () => { + it( 'should use up to one insert and remove operations (spellchecker)', () => { // This test case emulates spellchecker correction. const viewSelection = new ViewSelection(); viewSelection.collapse( viewRoot.getChild( 0 ).getChild( 0 ), 6 ); - sinon.spy( Batch.prototype, 'weakInsert' ); - sinon.spy( Batch.prototype, 'remove' ); + testUtils.sinon.spy( Batch.prototype, 'weakInsert' ); + testUtils.sinon.spy( Batch.prototype, 'remove' ); view.fire( 'mutations', [ { @@ -245,6 +232,69 @@ describe( 'Input feature', () => { expect( Batch.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 = new ViewSelection(); + viewSelection.collapse( viewRoot.getChild( 0 ).getChild( 0 ), 9 ); + + view.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' ); + expect( getViewData( view ) ).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 = new ViewSelection(); + viewSelection.collapse( viewRoot.getChild( 0 ).getChild( 0 ), 8 ); + + view.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' ); + expect( getViewData( view ) ).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 = new ViewSelection(); + viewSelection.collapse( viewRoot.getChild( 0 ).getChild( 0 ), 9 ); + + view.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[]' ); + expect( getViewData( view ) ).to.equal( '

Foo house{}

' ); + } ); + it( 'should replace last   with space', () => { model.enqueueChanges( () => { model.selection.setRanges( [ @@ -268,7 +318,7 @@ describe( 'Input feature', () => { it( 'should replace first   with space', () => { model.enqueueChanges( () => { model.selection.setRanges( [ - ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 6, modelRoot.getChild( 0 ), 6 ) + ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 0, modelRoot.getChild( 0 ), 0 ) ] ); } ); @@ -281,11 +331,17 @@ describe( 'Input feature', () => { } ] ); - expect( getModelData( model ) ).to.equal( ' foobar[]' ); - expect( getViewData( view ) ).to.equal( '

foobar{}

' ); + expect( getModelData( model ) ).to.equal( ' []foobar' ); + expect( getViewData( view ) ).to.equal( '

{}foobar

' ); } ); it( 'should replace all   with spaces', () => { + model.enqueueChanges( () => { + model.selection.setRanges( [ + ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 6, modelRoot.getChild( 0 ), 6 ) + ] ); + } ); + view.fire( 'mutations', [ { type: 'text', @@ -295,9 +351,8 @@ describe( 'Input feature', () => { } ] ); - expect( getModelData( model ) ).to.equal( 'foo[]bar baz' ); - expect( getViewData( view ) ).to.equal( '

foo{}bar' + - ' baz

' ); + expect( getModelData( model ) ).to.equal( 'foobar baz[]' ); + expect( getViewData( view ) ).to.equal( '

foobar baz{}

' ); } ); } ); @@ -380,19 +435,5 @@ describe( 'Input feature', () => { expect( getModelData( model ) ).to.equal( 'foo[]bar' ); } ); } ); - - describe( 'destroy', () => { - it( 'should destroy change buffer', () => { - const typing = new Input( new VirtualTestEditor() ); - typing.init(); - - const destroy = typing._buffer.destroy = sinon.spy(); - - typing.destroy(); - - expect( destroy.calledOnce ).to.be.true; - expect( typing._buffer ).to.be.null; - } ); - } ); } ); diff --git a/tests/inputcommand.js b/tests/inputcommand.js new file mode 100644 index 0000000..86ba6d6 --- /dev/null +++ b/tests/inputcommand.js @@ -0,0 +1,193 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import InputCommand from '../src/inputcommand'; +import ChangeBuffer from '../src/changebuffer'; +import Input from '../src/input'; + +describe( 'InputCommand', () => { + let editor, doc, buffer; + + testUtils.createSinonSandbox(); + + before( () => { + return ModelTestEditor.create( ) + .then( newEditor => { + editor = newEditor; + doc = editor.document; + + const inputCommand = new InputCommand( editor, 20 ); + editor.commands.set( 'input', inputCommand ); + + buffer = inputCommand.buffer; + + doc.schema.registerItem( 'p', '$block' ); + doc.schema.registerItem( 'h1', '$block' ); + } ); + } ); + + beforeEach( () => { + buffer.size = 0; + } ); + + describe( 'buffer', () => { + it( 'has buffer getter', () => { + expect( editor.commands.get( 'input' ).buffer ).to.be.an.instanceof( ChangeBuffer ); + } ); + + it( 'has a buffer limit configured to default value of 20', () => { + expect( editor.commands.get( 'input' )._buffer ).to.have.property( 'limit', 20 ); + } ); + + it( 'has a buffer configured to config.typing.undoStep', () => { + return VirtualTestEditor.create( { + plugins: [ Input ], + typing: { + undoStep: 5 + } + } ) + .then( editor => { + expect( editor.commands.get( 'input' )._buffer ).to.have.property( 'limit', 5 ); + } ); + } ); + } ); + + describe( 'execute', () => { + it( 'uses enqueueChanges', () => { + setData( doc, '

foo[]bar

' ); + + const spy = testUtils.sinon.spy( doc, 'enqueueChanges' ); + + editor.execute( 'input', { + text: '' + } ); + + expect( spy.calledOnce ).to.be.true; + } ); + + it( 'inserts text for collapsed range', () => { + setData( doc, '

foo[]

' ); + + editor.execute( 'input', { + text: 'bar', + range: editor.document.selection.getFirstRange() + } ); + + expect( getData( doc, { selection: true } ) ).to.be.equal( '

foobar[]

' ); + expect( buffer.size ).to.be.equal( 3 ); + } ); + + it( 'replaces text for range within single element on the beginning', () => { + setData( doc, '

[fooba]r

' ); + + editor.execute( 'input', { + text: 'rab', + range: editor.document.selection.getFirstRange() + } ); + + expect( getData( doc, { selection: true } ) ).to.be.equal( '

rab[]r

' ); + expect( buffer.size ).to.be.equal( 3 ); + } ); + + it( 'replaces text for range within single element in the middle', () => { + setData( doc, '

fo[oba]r

' ); + + editor.execute( 'input', { + text: 'bazz', + range: editor.document.selection.getFirstRange() + } ); + + expect( getData( doc, { selection: true } ) ).to.be.equal( '

fobazz[]r

' ); + expect( buffer.size ).to.be.equal( 4 ); + } ); + + it( 'replaces text for range within single element on the end', () => { + setData( doc, '

fooba[r]

' ); + + editor.execute( 'input', { + text: 'zzz', + range: editor.document.selection.getFirstRange() + } ); + + expect( getData( doc, { selection: true } ) ).to.be.equal( '

foobazzz[]

' ); + expect( buffer.size ).to.be.equal( 3 ); + } ); + + it( 'replaces text for range within multiple elements', () => { + setData( doc, '

F[OO

b]ar

' ); + + editor.execute( 'input', { + text: 'unny c', + range: editor.document.selection.getFirstRange() + } ); + + expect( getData( doc, { selection: true } ) ).to.be.equal( '

Funny c[

]ar

' ); + expect( buffer.size ).to.be.equal( 6 ); + } ); + + it( 'uses current selection when range is not given', () => { + setData( doc, '

foob[ar]

' ); + + editor.execute( 'input', { + text: 'az' + } ); + + expect( getData( doc, { selection: true } ) ).to.be.equal( '

foobaz[]

' ); + expect( buffer.size ).to.be.equal( 2 ); + } ); + + it( 'only removes content when empty text given', () => { + setData( doc, '

[fo]obar

' ); + + editor.execute( 'input', { + text: '', + range: editor.document.selection.getFirstRange() + } ); + + expect( getData( doc, { selection: true } ) ).to.be.equal( '

[]obar

' ); + expect( buffer.size ).to.be.equal( 0 ); + } ); + + it( 'only removes content when no text given (with default non-collapsed range)', () => { + setData( doc, '

[fo]obar

' ); + + const spy = testUtils.sinon.spy( doc, 'enqueueChanges' ); + + editor.execute( 'input' ); + + expect( spy.callCount ).to.be.equal( 1 ); + expect( getData( doc, { selection: true } ) ).to.be.equal( '

[]obar

' ); + expect( buffer.size ).to.be.equal( 0 ); + } ); + + it( 'does not change selection and content when no text given (with default collapsed range)', () => { + setData( doc, '

fo[]obar

' ); + + const spy = testUtils.sinon.spy( doc, 'enqueueChanges' ); + + editor.execute( 'input' ); + + expect( spy.callCount ).to.be.equal( 1 ); + expect( getData( doc, { selection: true } ) ).to.be.equal( '

fo[]obar

' ); + expect( buffer.size ).to.be.equal( 0 ); + } ); + } ); + + describe( 'destroy', () => { + it( 'should destroy change buffer', () => { + const command = editor.commands.get( 'input' ); + const destroy = command._buffer.destroy = testUtils.sinon.spy(); + + command.destroy(); + + expect( destroy.calledOnce ).to.be.true; + expect( command._buffer ).to.be.null; + } ); + } ); +} );