Ver Fonte

Merge pull request #8 from ckeditor/t/1

t/1: Initial implementation.
Piotrek Koszuliński há 9 anos atrás
pai
commit
52b399b8cc

+ 24 - 8
packages/ckeditor5-typing/src/changebuffer.js

@@ -5,7 +5,7 @@
 
 'use strict';
 
-import utils from '../utils/utils.js';
+import count from '../utils/count.js';
 
 /**
  * Change buffer allows to group atomic changes (like characters that have been typed) into
@@ -59,7 +59,13 @@ export default class ChangeBuffer {
 		 */
 		this.limit = limit;
 
-		this._changeCallback = ( evt, type, changes, batch ) => this._onBatch( batch );
+		this._changeCallback = ( evt, type, changes, batch ) => {
+			// See #7.
+			if ( batch ) {
+				this._onBatch( batch );
+			}
+		};
+
 		doc.on( 'change', this._changeCallback );
 
 		/**
@@ -79,7 +85,7 @@ export default class ChangeBuffer {
 
 	/**
 	 * Current batch to which a feature should add its deltas. Once the {@link typing.ChangeBuffer#size}
-	 * exceedes the {@link typing.ChangeBuffer#limit}, the batch is set to a new instance and size is reset.
+	 * reach or exceedes the {@link typing.ChangeBuffer#limit}, then the batch is set to a new instance and size is reset.
 	 *
 	 * @type {engine.treeModel.batch.Batch}
 	 */
@@ -93,15 +99,15 @@ export default class ChangeBuffer {
 
 	/**
 	 * Input number of changes into the buffer. Once the {@link typing.ChangeBuffer#size}
-	 * exceedes the {@link typing.ChangeBuffer#limit}, the batch is set to a new instance and size is reset.
+	 * reach or exceedes the {@link typing.ChangeBuffer#limit}, then the batch is set to a new instance and size is reset.
 	 *
 	 * @param {Number} changeCount Number of atomic changes to input.
 	 */
 	input( changeCount ) {
 		this.size += changeCount;
 
-		if ( this.size > this.limit ) {
-			this._batch = null;
+		if ( this.size >= this.limit ) {
+			this._reset();
 		}
 	}
 
@@ -125,8 +131,18 @@ export default class ChangeBuffer {
 	 */
 	_onBatch( batch ) {
 		// 1 operation means a newly created batch.
-		if ( batch !== this._batch && utils.count( batch.getOperations() ) <= 1 ) {
-			this._batch = null;
+		if ( batch !== this._batch && count( batch.getOperations() ) <= 1 ) {
+			this._reset();
 		}
 	}
+
+	/**
+	 * Resets change buffer.
+	 *
+	 * @private
+	 */
+	_reset() {
+		this._batch = null;
+		this.size = 0;
+	}
 }

+ 266 - 0
packages/ckeditor5-typing/src/typing.js

@@ -0,0 +1,266 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Feature from '../feature.js';
+import ChangeBuffer from './changebuffer.js';
+import ModelPosition from '../engine/model/position.js';
+import ModelRange from '../engine/model/range.js';
+import ViewPosition from '../engine/view/position.js';
+import ViewText from '../engine/view/text.js';
+import diff from '../utils/diff.js';
+import diffToChanges from '../utils/difftochanges.js';
+import { getCode } from '../utils/keyboard.js';
+
+/**
+ * The typing feature. Handles... typing.
+ *
+ * @memberOf typing
+ * @extends ckeditor5.Feature
+ */
+export default class Typing extends Feature {
+	/**
+	 * @inheritDoc
+	 */
+	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} typing.Typing#_buffer
+		 */
+		this._buffer = new ChangeBuffer( editor.document, editor.config.get( 'typing.undoStep' ) || 20 );
+
+		// TODO The above default config value should be defines using editor.config.define() once it's fixed.
+
+		this.listenTo( editingView, 'keydown', ( evt, data ) => {
+			this._handleKeydown( data );
+		}, null, 9999 ); // LOWEST
+
+		this.listenTo( editingView, 'mutations', ( evt, mutations ) => {
+			this._handleMutations( mutations );
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		super.destroy();
+
+		this._buffer.destroy();
+		this._buffer = null;
+	}
+
+	/**
+	 * Handles keydown event. We need to guess whether such a keystroke is going to result
+	 * in typing. If so, then before character insertion happens, we need to delete
+	 * any selected content. Otherwise, a default browser deletion mechanism would be
+	 * triggered, resulting in:
+	 *
+	 * * hundreds of mutations which couldn't be handled,
+	 * * but most importantly, loss of a control over how content is being deleted.
+	 *
+	 * The method is used in a low-prior listener, hence allowing other listeners (e.g. delete or enter features)
+	 * to handle the event.
+	 *
+	 * @private
+	 * @param {engine.view.observer.keyObserver.KeyEventData} evtData
+	 */
+	_handleKeydown( evtData ) {
+		const doc = this.editor.document;
+
+		if ( isSafeKeystroke( evtData ) || doc.selection.isCollapsed ) {
+			return;
+		}
+
+		doc.enqueueChanges( () => {
+			doc.composer.deleteContents( this._buffer.batch, doc.selection );
+		} );
+	}
+
+	/**
+	 * Handles DOM mutations.
+	 *
+	 * @param {Array.<engine.view.Document~MutatatedText|engine.view.Document~MutatatedChildren>} mutations
+	 */
+	_handleMutations( mutations ) {
+		const doc = this.editor.document;
+		const handler = new MutationHandler( this.editor.editing, this._buffer );
+
+		doc.enqueueChanges( () => handler.handle( mutations ) );
+	}
+}
+
+/**
+ * Helper class for translating DOM mutations into model changes.
+ *
+ * @private
+ * @member typing.typing
+ */
+class MutationHandler {
+	/**
+	 * Creates instance of the mutation handler.
+	 *
+	 * @param {engine.EditingController} editing
+	 * @param {typing.ChangeBuffer} buffer
+	 */
+	constructor( editing, buffer ) {
+		/**
+		 * The editing controller.
+		 *
+		 * @member {engine.EditingController} typing.typing.MutationHandler#editing
+		 */
+		this.editing = editing;
+
+		/**
+		 * The change buffer.
+		 *
+		 * @member {engine.EditingController} typing.typing.MutationHandler#buffer
+		 */
+		this.buffer = buffer;
+
+		/**
+		 * Number of inserted characters which need to be feed to the {@link #buffer change buffer}
+		 * on {@link #commit}.
+		 *
+		 * @member {Number} typing.typing.MutationHandler#insertedCharacterCount
+		 */
+		this.insertedCharacterCount = 0;
+
+		/**
+		 * Position to which the selection should be moved on {@link #commit}.
+		 *
+		 * Note: Currently, the mutation handler will move selection to the position set by the
+		 * last consumer. Placing the selection right after the last change will work for many cases, but not
+		 * for ones like autocorrection or spellchecking. The caret should be placed after the whole piece
+		 * which was corrected (e.g. a word), not after the letter that was replaced.
+		 *
+		 * @member {engine.model.Position} typing.typing.MutationHandler#selectionPosition
+		 */
+	}
+
+	/**
+	 * Handle given mutations.
+	 *
+	 * @param {Array.<engine.view.Document~MutatatedText|engine.view.Document~MutatatedChildren>} mutations
+	 */
+	handle( mutations ) {
+		for ( let mutation of mutations ) {
+			// Fortunately it will never be both.
+			this._handleTextMutation( mutation );
+			this._handleTextNodeInsertion( mutation );
+		}
+
+		this.buffer.input( Math.max( this.insertedCharacterCount, 0 ) );
+
+		if ( this.selectionPosition ) {
+			this.editing.model.selection.collapse( this.selectionPosition );
+		}
+	}
+
+	_handleTextMutation( mutation ) {
+		if ( mutation.type != 'text' ) {
+			return;
+		}
+
+		const changes = diffToChanges( diff( mutation.oldText, mutation.newText ), mutation.newText );
+
+		for ( let change of changes ) {
+			const viewPos = new ViewPosition( mutation.node, change.index );
+			const modelPos = this.editing.mapper.toModelPosition( viewPos );
+
+			if ( change.type == 'INSERT' ) {
+				const insertedText = change.values.join( '' );
+
+				this._insert( modelPos, insertedText );
+
+				this.selectionPosition = ModelPosition.createAt( modelPos.parent, modelPos.offset + insertedText.length );
+			} else /* if ( change.type == 'DELETE' ) */ {
+				this._remove( new ModelRange( modelPos, modelPos.getShiftedBy( change.howMany ) ), change.howMany );
+
+				this.selectionPosition = modelPos;
+			}
+		}
+	}
+
+	_handleTextNodeInsertion( mutation ) {
+		if ( mutation.type != 'children' ) {
+			return;
+		}
+
+		// One new node.
+		if ( mutation.newChildren.length - mutation.oldChildren.length != 1 ) {
+			return false;
+		}
+		// Which is text.
+		const changes = diffToChanges( diff( mutation.oldChildren, mutation.newChildren ), mutation.newChildren );
+		const change = changes[ 0 ];
+
+		if ( !( change.values[ 0 ] instanceof ViewText ) ) {
+			return false;
+		}
+
+		const viewPos = new ViewPosition( mutation.node, change.index );
+		const modelPos = this.editing.mapper.toModelPosition( viewPos );
+		const insertedText = mutation.newChildren[ 0 ].data;
+
+		this._insert( modelPos, insertedText );
+
+		this.selectionPosition = ModelPosition.createAt( modelPos.parent, 'END' );
+	}
+
+	_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 safeKeycodes = [
+	getCode( 'arrowUp' ),
+	getCode( 'arrowRight' ),
+	getCode( 'arrowDown' ),
+	getCode( 'arrowLeft' ),
+	16, // Shift
+	17, // Ctrl
+	18, // Alt
+	20, // CapsLock
+	27, // Escape
+	33, // PageUp
+	34, // PageDown
+	35, // Home
+	36, // End
+];
+
+// Function keys.
+for ( let code = 112; code <= 135; code++ ) {
+	safeKeycodes.push( code );
+}
+
+// Returns true if a keystroke should not cause any content change caused by "typing".
+//
+// Note: this implementation is very simple and will need to be refined with time.
+//
+// @param {engine.view.observer.keyObserver.KeyEventData} keyData
+// @returns {Boolean}
+function isSafeKeystroke( keyData ) {
+	// Keystrokes which contain Ctrl don't represent typing.
+	if ( keyData.ctrlKey ) {
+		return true;
+	}
+
+	return safeKeycodes.includes( keyData.keyCode );
+}

+ 35 - 5
packages/ckeditor5-typing/tests/changebuffer.js

@@ -6,9 +6,11 @@
 'use strict';
 
 import ChangeBuffer from '/ckeditor5/typing/changebuffer.js';
-import Document from '/ckeditor5/engine/treemodel/document.js';
-import Batch from '/ckeditor5/engine/treemodel/batch.js';
-import Position from '/ckeditor5/engine/treemodel/position.js';
+import Document from '/ckeditor5/engine/model/document.js';
+import Batch from '/ckeditor5/engine/model/batch.js';
+import Position from '/ckeditor5/engine/model/position.js';
+import InsertDelta from '/ckeditor5/engine/model/delta/insertdelta.js';
+import InsertOperation from '/ckeditor5/engine/model/operation/insertoperation.js';
 
 describe( 'ChangeBuffer', () => {
 	const CHANGE_LIMIT = 3;
@@ -34,10 +36,10 @@ describe( 'ChangeBuffer', () => {
 			expect( buffer.batch ).to.be.instanceof( Batch );
 		} );
 
-		it( 'is reset once changes exceed the limit', () => {
+		it( 'is reset once changes reaches the limit', () => {
 			const batch1 = buffer.batch;
 
-			buffer.input( CHANGE_LIMIT );
+			buffer.input( CHANGE_LIMIT - 1 );
 
 			expect( buffer.batch ).to.equal( batch1 );
 
@@ -47,14 +49,29 @@ describe( 'ChangeBuffer', () => {
 
 			expect( batch2 ).to.be.instanceof( Batch );
 			expect( batch2 ).to.not.equal( batch1 );
+			expect( buffer.size ).to.equal( 0 );
+		} );
+
+		it( 'is reset once changes exceedes the limit', () => {
+			const batch1 = buffer.batch;
+
+			// Exceed the limit with one big jump to ensure that >= operator was used.
+			buffer.input( CHANGE_LIMIT + 1 );
+
+			expect( buffer.batch ).to.not.equal( batch1 );
+			expect( buffer.size ).to.equal( 0 );
 		} );
 
 		it( 'is reset once a new batch appears in the document', () => {
 			const batch1 = buffer.batch;
 
+			// Ensure that size is reset too.
+			buffer.input( 1 );
+
 			doc.batch().insert( Position.createAt( root, 0 ), 'a' );
 
 			expect( buffer.batch ).to.not.equal( batch1 );
+			expect( buffer.size ).to.equal( 0 );
 		} );
 
 		it( 'is not reset when changes are added to the buffer\'s batch', () => {
@@ -80,6 +97,19 @@ describe( 'ChangeBuffer', () => {
 			doc.batch().insert( Position.createAt( root, 0 ), 'c' );
 			expect( buffer.batch ).to.not.equal( bufferBatch );
 		} );
+
+		// See #7.
+		it( 'is not reset when changes are applied without a batch', () => {
+			const bufferBatch = buffer.batch;
+
+			const delta = new InsertDelta();
+			const insert = new InsertOperation( Position.createAt( root, 0 ), 'a', doc.version );
+
+			delta.addOperation( insert );
+			doc.applyOperation( insert );
+
+			expect( buffer.batch ).to.equal( bufferBatch );
+		} );
 	} );
 
 	describe( 'destory', () => {

+ 244 - 0
packages/ckeditor5-typing/tests/typing.js

@@ -0,0 +1,244 @@
+/*
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import VirtualTestEditor from '/tests/ckeditor5/_utils/virtualtesteditor.js';
+import Typing from '/ckeditor5/typing/typing.js';
+import Paragraph from '/ckeditor5/paragraph/paragraph.js';
+
+import ModelRange from '/ckeditor5/engine/model/range.js';
+
+import ViewText from '/ckeditor5/engine/view/text.js';
+import ViewElement from '/ckeditor5/engine/view/element.js';
+
+import EmitterMixin from '/ckeditor5/utils/emittermixin.js';
+import { getCode } from '/ckeditor5/utils/keyboard.js';
+
+import { getData as getModelData } from '/tests/engine/_utils/model.js';
+import { getData as getViewData } from '/tests/engine/_utils/view.js';
+
+describe( 'Typing feature', () => {
+	let editor, model, modelRoot, view, viewRoot, listenter;
+
+	before( () => {
+		listenter = Object.create( EmitterMixin );
+
+		return VirtualTestEditor.create( {
+				features: [ Typing, Paragraph ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.editing.model;
+				modelRoot = model.getRoot();
+				view = editor.editing.view;
+				viewRoot = view.getRoot();
+			} );
+	} );
+
+	beforeEach( () => {
+		editor.setData( '<p>foobar</p>' );
+
+		model.enqueueChanges( () => {
+			model.selection.setRanges( [
+				ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 3, modelRoot.getChild( 0 ), 3 ) ] );
+		} );
+	} );
+
+	afterEach( () => {
+		listenter.stopListening();
+	} );
+
+	it( 'has a buffer configured to default value of config.typing.undoStep', () => {
+		expect( editor.plugins.get( Typing )._buffer ).to.have.property( 'limit', 20 );
+	} );
+
+	it( 'has a buffer configured to config.typing.undoStep', () => {
+		return VirtualTestEditor.create( {
+				features: [ Typing ],
+				typing: {
+					undoStep: 5
+				}
+			} )
+			.then( editor => {
+				expect( editor.plugins.get( Typing )._buffer ).to.have.property( 'limit', 5 );
+			} );
+	} );
+
+	describe( 'mutations handling', () => {
+		it( 'should handle text mutation', () => {
+			view.fire( 'mutations', [
+				{
+					type: 'text',
+					oldText: 'foobar',
+					newText: 'fooxbar',
+					node: viewRoot.getChild( 0 ).getChild( 0 )
+				}
+			] );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foox<selection />bar</paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p>foox{}bar</p>' );
+		} );
+
+		it( 'should handle text mutation change', () => {
+			view.fire( 'mutations', [
+				{
+					type: 'text',
+					oldText: 'foobar',
+					newText: 'foodar',
+					node: viewRoot.getChild( 0 ).getChild( 0 )
+				}
+			] );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>food<selection />ar</paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p>food{}ar</p>' );
+		} );
+
+		it( 'should handle text node insertion', () => {
+			editor.setData( '<p></p>' );
+
+			view.fire( 'mutations', [
+				{
+					type: 'children',
+					oldChildren: [],
+					newChildren: [ new ViewText( 'x' ) ],
+					node: viewRoot.getChild( 0 )
+				}
+			] );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>x<selection /></paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p>x{}</p>' );
+		} );
+
+		it( 'should do nothing when two nodes where inserted', () => {
+			editor.setData( '<p></p>' );
+
+			view.fire( 'mutations', [
+				{
+					type: 'children',
+					oldChildren: [],
+					newChildren: [ new ViewText( 'x' ), new ViewElement( 'img' ) ],
+					node: viewRoot.getChild( 0 )
+				}
+			] );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph></paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p></p>' );
+		} );
+
+		it( 'should do nothing when node was removed', () => {
+			view.fire( 'mutations', [
+				{
+					type: 'children',
+					oldChildren: [ viewRoot.getChild( 0 ).getChild( 0 ) ],
+					newChildren: [],
+					node: viewRoot.getChild( 0 )
+				}
+			] );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foo<selection />bar</paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p>foo{}bar</p>' );
+		} );
+
+		it( 'should do nothing when element was inserted', () => {
+			editor.setData( '<p></p>' );
+
+			view.fire( 'mutations', [
+				{
+					type: 'children',
+					oldChildren: [],
+					newChildren: [ new ViewElement( 'img' ) ],
+					node: viewRoot.getChild( 0 )
+				}
+			] );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph></paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p></p>' );
+		} );
+	} );
+
+	describe( 'keystroke handling', () => {
+		it( 'should remove contents', () => {
+			model.enqueueChanges( () => {
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) ] );
+			} );
+
+			listenter.listenTo( view, 'keydown', () => {
+				expect( getModelData( model ) ).to.equal( '<paragraph>fo<selection />ar</paragraph>' );
+
+				view.fire( 'mutations', [
+					{
+						type: 'text',
+						oldText: 'foar',
+						newText: 'foyar',
+						node: viewRoot.getChild( 0 ).getChild( 0 )
+					}
+				] );
+			}, null, 1000000 );
+
+			view.fire( 'keydown', { keyCode: getCode( 'y' ) } );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foy<selection />ar</paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p>foy{}ar</p>' );
+		} );
+
+		it( 'should do nothing on arrow key', () => {
+			model.enqueueChanges( () => {
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) ] );
+			} );
+
+			view.fire( 'keydown', { keyCode: getCode( 'arrowright' ) } );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>fo<selection>ob</selection>ar</paragraph>' );
+		} );
+
+		it( 'should do nothing on ctrl combinations', () => {
+			model.enqueueChanges( () => {
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) ] );
+			} );
+
+			view.fire( 'keydown', { ctrlKey: true, keyCode: getCode( 'c' ) } );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>fo<selection>ob</selection>ar</paragraph>' );
+		} );
+
+		it( 'should do nothing on non printable keys', () => {
+			model.enqueueChanges( () => {
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) ] );
+			} );
+
+			view.fire( 'keydown', { keyCode: 16 } ); // Shift
+			view.fire( 'keydown', { keyCode: 35 } ); // Home
+			view.fire( 'keydown', { keyCode: 112 } ); // F1
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>fo<selection>ob</selection>ar</paragraph>' );
+		} );
+
+		it( 'should do nothing if selection is collapsed', () => {
+			view.fire( 'keydown', { ctrlKey: true, keyCode: getCode( 'c' ) } );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foo<selection />bar</paragraph>' );
+		} );
+	} );
+
+	describe( 'destroy', () => {
+		it( 'should destroy change buffer', () => {
+			const typing = new Typing( 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;
+		} );
+	} );
+} );
+