Explorar o código

Merge pull request #80 from ckeditor/t/21

Fix: New undo step should be created on selection change or applying an attribute. Closes #20. Closes #21.
Piotrek Koszuliński %!s(int64=9) %!d(string=hai) anos
pai
achega
2ea8d34caa

+ 48 - 6
packages/ckeditor5-typing/src/changebuffer.js

@@ -60,12 +60,28 @@ export default class ChangeBuffer {
 		 */
 		this.limit = limit;
 
+		/**
+		 * Whether the buffer is locked. The locked buffer cannot be reset unless it gets unlocked.
+		 *
+		 * @readonly
+		 * @member {Boolean} #isLocked
+		 */
+		this.isLocked = false;
+
 		this._changeCallback = ( evt, type, changes, batch ) => {
 			this._onBatch( batch );
 		};
 
+		this._selectionChangeCallback = () => {
+			this._reset();
+		};
+
 		doc.on( 'change', this._changeCallback );
 
+		doc.selection.on( 'change:range', this._selectionChangeCallback );
+
+		doc.selection.on( 'change:attribute', this._selectionChangeCallback );
+
 		/**
 		 * The current batch instance.
 		 *
@@ -79,13 +95,20 @@ export default class ChangeBuffer {
 		 * @private
 		 * @member #_changeCallback
 		 */
+
+		/**
+		 * The callback to document selection change:attribute and change:range events which resets the buffer.
+		 *
+		 * @private
+		 * @member #_selectionChangeCallback
+		 */
 	}
 
 	/**
 	 * The current batch to which a feature should add its deltas. Once the {@link #size}
 	 * is reached or exceeds the {@link #limit}, the batch is set to a new instance and the size is reset.
 	 *
-	 * @type {engine.treeModel.batch.Batch}
+	 * @type {module:engine/model/batch~Batch}
 	 */
 	get batch() {
 		if ( !this._batch ) {
@@ -105,15 +128,31 @@ export default class ChangeBuffer {
 		this.size += changeCount;
 
 		if ( this.size >= this.limit ) {
-			this._reset();
+			this._reset( true );
 		}
 	}
 
+	/**
+	 * Locks the buffer.
+	 */
+	lock() {
+		this.isLocked = true;
+	}
+
+	/**
+	 * Unlocks the buffer.
+	 */
+	unlock() {
+		this.isLocked = false;
+	}
+
 	/**
 	 * Destroys the buffer.
 	 */
 	destroy() {
 		this.document.off( 'change', this._changeCallback );
+		this.document.selection.off( 'change:range', this._selectionChangeCallback );
+		this.document.selection.off( 'change:attribute', this._selectionChangeCallback );
 	}
 
 	/**
@@ -130,7 +169,7 @@ export default class ChangeBuffer {
 	_onBatch( batch ) {
 		// One operation means a newly created batch.
 		if ( batch.type != 'transparent' && batch !== this._batch && count( batch.getOperations() ) <= 1 ) {
-			this._reset();
+			this._reset( true );
 		}
 	}
 
@@ -138,9 +177,12 @@ export default class ChangeBuffer {
 	 * Resets the change buffer.
 	 *
 	 * @private
+	 * @param {Boolean} [ignoreLock] Whether internal lock {@link #isLocked} should be ignored.
 	 */
-	_reset() {
-		this._batch = null;
-		this.size = 0;
+	_reset( ignoreLock ) {
+		if ( !this.isLocked || ignoreLock ) {
+			this._batch = null;
+			this.size = 0;
+		}
 	}
 }

+ 4 - 0
packages/ckeditor5-typing/src/deletecommand.js

@@ -61,6 +61,8 @@ export default class DeleteCommand extends Command {
 		const dataController = this.editor.data;
 
 		doc.enqueueChanges( () => {
+			this._buffer.lock();
+
 			const selection = Selection.createFromSelection( doc.selection );
 
 			// Try to extend the selection in the specified direction.
@@ -85,6 +87,8 @@ export default class DeleteCommand extends Command {
 			this._buffer.input( changeCount );
 
 			doc.selection.setRanges( selection.getRanges(), selection.isBackward );
+
+			this._buffer.unlock();
 		} );
 	}
 }

+ 4 - 0
packages/ckeditor5-typing/src/input.js

@@ -66,9 +66,13 @@ export default class Input extends Plugin {
 			return;
 		}
 
+		buffer.lock();
+
 		doc.enqueueChanges( () => {
 			this.editor.data.deleteContent( doc.selection, buffer.batch );
 		} );
+
+		buffer.unlock();
 	}
 
 	/**

+ 4 - 0
packages/ckeditor5-typing/src/inputcommand.js

@@ -78,6 +78,8 @@ export default class InputCommand extends Command {
 		doc.enqueueChanges( () => {
 			const isCollapsedRange = range.isCollapsed;
 
+			this._buffer.lock();
+
 			if ( !isCollapsedRange ) {
 				this._buffer.batch.remove( range );
 			}
@@ -91,6 +93,8 @@ export default class InputCommand extends Command {
 				this.editor.data.model.selection.collapse( range.start.getShiftedBy( textInsertions ) );
 			}
 
+			this._buffer.unlock();
+
 			this._buffer.input( textInsertions );
 		} );
 	}

+ 141 - 0
packages/ckeditor5-typing/tests/changebuffer.js

@@ -23,6 +23,7 @@ describe( 'ChangeBuffer', () => {
 			expect( buffer ).to.have.property( 'document', doc );
 			expect( buffer ).to.have.property( 'limit', CHANGE_LIMIT );
 			expect( buffer ).to.have.property( 'size', 0 );
+			expect( buffer ).to.have.property( 'isLocked', false );
 		} );
 
 		it( 'sets limit property according to default value', () => {
@@ -32,6 +33,26 @@ describe( 'ChangeBuffer', () => {
 		} );
 	} );
 
+	describe( 'locking', () => {
+		it( 'is unlocked by default', () => {
+			expect( buffer.isLocked ).to.be.false;
+		} );
+
+		it( 'is locked by lock method', () => {
+			buffer.lock();
+
+			expect( buffer.isLocked ).to.be.true;
+		} );
+
+		it( 'is unlocked by unlock method', () => {
+			buffer.isLocked = true;
+
+			buffer.unlock();
+
+			expect( buffer.isLocked ).to.be.false;
+		} );
+	} );
+
 	describe( 'batch', () => {
 		it( 'it is set initially', () => {
 			expect( buffer ).to.have.property( 'batch' );
@@ -107,6 +128,106 @@ describe( 'ChangeBuffer', () => {
 
 			expect( buffer.batch ).to.equal( bufferBatch );
 		} );
+
+		it( 'is not reset while locked', () => {
+			const initialBatch = buffer.batch;
+
+			buffer.lock();
+
+			buffer.input( 1 );
+			buffer._reset();
+
+			buffer.unlock();
+
+			expect( buffer.batch ).to.be.equal( initialBatch );
+			expect( buffer.size ).to.equal( 1 );
+		} );
+
+		it( 'is reset while locked with ignoreLock used', () => {
+			const initialBatch = buffer.batch;
+
+			buffer.lock();
+
+			buffer.input( 1 );
+			buffer._reset( true );
+
+			buffer.unlock();
+
+			expect( buffer.batch ).to.not.equal( initialBatch );
+			expect( buffer.size ).to.equal( 0 );
+		} );
+
+		it( 'is reset while locked and limit exceeded', () => {
+			const initialBatch = buffer.batch;
+
+			buffer.lock();
+
+			buffer.input( CHANGE_LIMIT + 1 );
+
+			buffer.unlock();
+
+			expect( buffer.batch ).to.not.equal( initialBatch );
+			expect( buffer.size ).to.equal( 0 );
+		} );
+
+		it( 'is reset while locked and new batch is applied', () => {
+			const initialBatch = buffer.batch;
+
+			buffer.lock();
+
+			doc.batch().insert( Position.createAt( root, 0 ), 'a' );
+
+			buffer.unlock();
+
+			expect( buffer.batch ).to.not.equal( initialBatch );
+			expect( buffer.size ).to.equal( 0 );
+		} );
+
+		it( 'is reset on selection change:range', () => {
+			const initialBatch = buffer.batch;
+
+			doc.selection.fire( 'change:range' );
+
+			expect( buffer.batch ).to.not.equal( initialBatch );
+			expect( buffer.size ).to.equal( 0 );
+		} );
+
+		it( 'is reset on selection change:attribute', () => {
+			const initialBatch = buffer.batch;
+
+			doc.selection.fire( 'change:attribute' );
+
+			expect( buffer.batch ).to.not.equal( initialBatch );
+			expect( buffer.size ).to.equal( 0 );
+		} );
+
+		it( 'is not reset on selection change:range while locked', () => {
+			const initialBatch = buffer.batch;
+			buffer.size = 1;
+
+			buffer.lock();
+
+			doc.selection.fire( 'change:range' );
+
+			buffer.unlock();
+
+			expect( buffer.batch ).to.be.equal( initialBatch );
+			expect( buffer.size ).to.equal( 1 );
+		} );
+
+		it( 'is not reset on selection change:attribute while locked', () => {
+			const initialBatch = buffer.batch;
+			buffer.size = 1;
+
+			buffer.lock();
+
+			doc.selection.fire( 'change:attribute' );
+
+			buffer.unlock();
+
+			expect( buffer.batch ).to.be.equal( initialBatch );
+			expect( buffer.size ).to.equal( 1 );
+		} );
 	} );
 
 	describe( 'destroy', () => {
@@ -119,5 +240,25 @@ describe( 'ChangeBuffer', () => {
 
 			expect( buffer.batch ).to.equal( batch1 );
 		} );
+
+		it( 'offs the buffer from the selection change:range', () => {
+			const batch1 = buffer.batch;
+
+			buffer.destroy();
+
+			doc.selection.fire( 'change:attribute' );
+
+			expect( buffer.batch ).to.equal( batch1 );
+		} );
+
+		it( 'offs the buffer from the selection change:attribute', () => {
+			const batch1 = buffer.batch;
+
+			buffer.destroy();
+
+			doc.selection.fire( 'change:range' );
+
+			expect( buffer.batch ).to.equal( batch1 );
+		} );
 	} );
 } );

+ 17 - 1
packages/ckeditor5-typing/tests/deletecommand.js

@@ -6,10 +6,13 @@
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
 import DeleteCommand from '../src/deletecommand';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
 describe( 'DeleteCommand', () => {
 	let editor, doc;
 
+	testUtils.createSinonSandbox();
+
 	beforeEach( () => {
 		return ModelTestEditor.create( )
 			.then( newEditor => {
@@ -33,13 +36,26 @@ describe( 'DeleteCommand', () => {
 		it( 'uses enqueueChanges', () => {
 			setData( doc, '<p>foo[]bar</p>' );
 
-			const spy = sinon.spy( doc, 'enqueueChanges' );
+			const spy = testUtils.sinon.spy( doc, 'enqueueChanges' );
 
 			editor.execute( 'delete' );
 
 			expect( spy.calledOnce ).to.be.true;
 		} );
 
+		it( 'locks buffer when executing', () => {
+			setData( doc, '<p>foo[]bar</p>' );
+
+			const buffer = editor.commands.get( 'delete' )._buffer;
+			const lockSpy = testUtils.sinon.spy( buffer, 'lock' );
+			const unlockSpy = testUtils.sinon.spy( buffer, 'unlock' );
+
+			editor.execute( 'delete' );
+
+			expect( lockSpy.calledOnce ).to.be.true;
+			expect( unlockSpy.calledOnce ).to.be.true;
+		} );
+
 		it( 'deletes previous character when selection is collapsed', () => {
 			setData( doc, '<p>foo[]bar</p>' );
 

+ 42 - 0
packages/ckeditor5-typing/tests/input.js

@@ -434,6 +434,48 @@ describe( 'Input feature', () => {
 
 			expect( getModelData( model ) ).to.equal( '<paragraph>foo[]bar</paragraph>' );
 		} );
+
+		it( 'should lock buffer if selection is not collapsed', () => {
+			const buffer = editor.commands.get( 'input' )._buffer;
+			const lockSpy = testUtils.sinon.spy( buffer, 'lock' );
+			const unlockSpy = testUtils.sinon.spy( buffer, 'unlock' );
+
+			model.enqueueChanges( () => {
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) ] );
+			} );
+
+			view.fire( 'keydown', { keyCode: getCode( 'y' ) } );
+
+			expect( lockSpy.calledOnce ).to.be.true;
+			expect( unlockSpy.calledOnce ).to.be.true;
+		} );
+
+		it( 'should not lock buffer on non printable keys', () => {
+			const buffer = editor.commands.get( 'input' )._buffer;
+			const lockSpy = testUtils.sinon.spy( buffer, 'lock' );
+			const unlockSpy = testUtils.sinon.spy( buffer, 'unlock' );
+
+			view.fire( 'keydown', { keyCode: 16 } ); // Shift
+			view.fire( 'keydown', { keyCode: 35 } ); // Home
+			view.fire( 'keydown', { keyCode: 112 } ); // F1
+
+			expect( lockSpy.callCount ).to.be.equal( 0 );
+			expect( unlockSpy.callCount ).to.be.equal( 0 );
+		} );
+
+		it( 'should not lock buffer on collapsed selection', () => {
+			const buffer = editor.commands.get( 'input' )._buffer;
+			const lockSpy = testUtils.sinon.spy( buffer, 'lock' );
+			const unlockSpy = testUtils.sinon.spy( buffer, 'unlock' );
+
+			view.fire( 'keydown', { keyCode: getCode( 'b' ) } );
+			view.fire( 'keydown', { keyCode: getCode( 'a' ) } );
+			view.fire( 'keydown', { keyCode: getCode( 'z' ) } );
+
+			expect( lockSpy.callCount ).to.be.equal( 0 );
+			expect( unlockSpy.callCount ).to.be.equal( 0 );
+		} );
 	} );
 } );
 

+ 211 - 0
packages/ckeditor5-typing/tests/inputcommand-integration.js

@@ -0,0 +1,211 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+
+import Typing from '../src/typing';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+
+import Range from '@ckeditor/ckeditor5-engine/src/model/range';
+import Position from '@ckeditor/ckeditor5-engine/src/model/position';
+
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getModelData } from  '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+
+describe( 'InputCommand integration', () => {
+	let editor, doc, viewDocument, boldView, italicView;
+
+	beforeEach( () => {
+		const editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, {
+			plugins: [ Typing, Paragraph, Undo, Bold, Italic, Enter ],
+			typing: { undoStep: 3 }
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+			doc = editor.document;
+			viewDocument = editor.editing.view;
+
+			boldView = editor.ui.componentFactory.create( 'bold' );
+			italicView = editor.ui.componentFactory.create( 'italic' );
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	function expectOutput( modelOutput, viewOutput ) {
+		expect( getModelData( editor.document ) ).to.equal( modelOutput );
+		expect( getViewData( viewDocument ) ).to.equal( viewOutput );
+	}
+
+	function simulateTyping( text ) {
+		// While typing, every character is an atomic change.
+		text.split( '' ).forEach( ( character ) => {
+			editor.execute( 'input', {
+				text: character
+			} );
+		} );
+	}
+
+	function simulateBatches( batches ) {
+		// Use longer text at once in input command.
+		batches.forEach( ( batch ) => {
+			editor.execute( 'input', {
+				text: batch
+			} );
+		} );
+	}
+
+	function setSelection( pathA, pathB ) {
+		doc.selection.setRanges( [ new Range( new Position( doc.getRoot(), pathA ), new Position( doc.getRoot(), pathB ) ) ] );
+	}
+
+	describe( 'InputCommand integration', () => {
+		it( 'resets the buffer on typing respecting typing.undoStep', () => {
+			setModelData( doc, '<paragraph>0[]</paragraph>' );
+
+			simulateTyping( '123456789' );
+
+			expectOutput( '<paragraph>0123456789[]</paragraph>', '<p>0123456789{}</p>' );
+
+			editor.execute( 'undo' );
+
+			expectOutput( '<paragraph>0123456[]</paragraph>', '<p>0123456{}</p>' );
+
+			editor.execute( 'undo' );
+
+			expectOutput( '<paragraph>0123[]</paragraph>', '<p>0123{}</p>' );
+
+			editor.execute( 'redo' );
+
+			expectOutput( '<paragraph>0123456[]</paragraph>', '<p>0123456{}</p>' );
+		} );
+
+		it( 'resets the buffer on text insertion respecting typing.undoStep', () => {
+			setModelData( doc, '<paragraph>0[]</paragraph>' );
+
+			simulateBatches( [ '1234', '5', '678', '9' ] );
+
+			expectOutput( '<paragraph>0123456789[]</paragraph>', '<p>0123456789{}</p>' );
+
+			editor.execute( 'undo' );
+
+			expectOutput( '<paragraph>012345678[]</paragraph>', '<p>012345678{}</p>' );
+
+			editor.execute( 'undo' );
+
+			expectOutput( '<paragraph>01234[]</paragraph>', '<p>01234{}</p>' );
+
+			editor.execute( 'redo' );
+
+			expectOutput( '<paragraph>012345678[]</paragraph>', '<p>012345678{}</p>' );
+		} );
+
+		it( 'resets the buffer when selection changes', () => {
+			setModelData( doc, '<paragraph>Foo[] Bar</paragraph>' );
+
+			setSelection( [ 0, 5 ], [ 0, 5 ] );
+			simulateTyping( '1' );
+
+			setSelection( [ 0, 7 ], [ 0, 8 ] );
+			simulateTyping( '2' );
+
+			expectOutput( '<paragraph>Foo B1a2[]</paragraph>', '<p>Foo B1a2{}</p>' );
+
+			editor.execute( 'undo' );
+
+			expectOutput( '<paragraph>Foo B1a[r]</paragraph>', '<p>Foo B1a{r}</p>' );
+
+			editor.execute( 'undo' );
+
+			expectOutput( '<paragraph>Foo B[]ar</paragraph>', '<p>Foo B{}ar</p>' );
+
+			editor.execute( 'redo' );
+
+			expectOutput( '<paragraph>Foo B1[]ar</paragraph>', '<p>Foo B1{}ar</p>' );
+		} );
+
+		it( 'resets the buffer when selection changes (with enter)', () => {
+			setModelData( doc, '<paragraph>Foo[]Bar</paragraph>' );
+
+			simulateTyping( '1' );
+			editor.execute( 'enter' );
+
+			setSelection( [ 1, 3 ], [ 1, 3 ] );
+			simulateTyping( '2' );
+			editor.execute( 'enter' );
+
+			simulateTyping( 'Baz' );
+
+			expectOutput( '<paragraph>Foo1</paragraph><paragraph>Bar2</paragraph><paragraph>Baz[]</paragraph>',
+				'<p>Foo1</p><p>Bar2</p><p>Baz{}</p>' );
+
+			editor.execute( 'undo' );
+
+			expectOutput( '<paragraph>Foo1</paragraph><paragraph>Bar2</paragraph><paragraph>[]</paragraph>',
+				'<p>Foo1</p><p>Bar2</p><p>[]</p>' );
+
+			editor.execute( 'undo' );
+
+			expectOutput( '<paragraph>Foo1</paragraph><paragraph>Bar2[]</paragraph>',
+				'<p>Foo1</p><p>Bar2{}</p>' );
+
+			editor.execute( 'undo' );
+
+			expectOutput( '<paragraph>Foo1</paragraph><paragraph>Bar[]</paragraph>',
+				'<p>Foo1</p><p>Bar{}</p>' );
+
+			editor.execute( 'redo' );
+
+			expectOutput( '<paragraph>Foo1</paragraph><paragraph>Bar2[]</paragraph>',
+				'<p>Foo1</p><p>Bar2{}</p>' );
+		} );
+
+		it( 'resets the buffer when attribute changes', () => {
+			setModelData( doc, '<paragraph>Foo[] Bar</paragraph>' );
+
+			simulateTyping( ' ' );
+
+			boldView.fire( 'execute' );
+			simulateTyping( 'B' );
+
+			italicView.fire( 'execute' );
+			simulateTyping( 'a' );
+
+			boldView.fire( 'execute' );
+			italicView.fire( 'execute' );
+			simulateTyping( 'z' );
+
+			expectOutput( '<paragraph>Foo <$text bold="true">B</$text><$text italic="true"><$text bold="true">a</$text></$text>z[] Bar</paragraph>',
+				'<p>Foo <strong>B</strong><em><strong>a</strong></em>z{} Bar</p>' );
+
+			editor.execute( 'undo' );
+
+			expectOutput(
+				'<paragraph>' +
+					'Foo <$text bold="true">B</$text><$text italic="true"><$text bold="true">a</$text></$text>' +
+					'<$text bold="true" italic="true">[]</$text> Bar' +
+				'</paragraph>',
+				'<p>Foo <strong>B</strong><em><strong>a{}</strong></em> Bar</p>'
+			);
+
+			editor.execute( 'undo' );
+
+			expectOutput( '<paragraph>Foo <$text bold="true">B[]</$text> Bar</paragraph>',
+				'<p>Foo <strong>B{}</strong> Bar</p>' );
+		} );
+	} );
+} );

+ 14 - 0
packages/ckeditor5-typing/tests/inputcommand.js

@@ -71,6 +71,20 @@ describe( 'InputCommand', () => {
 			expect( spy.calledOnce ).to.be.true;
 		} );
 
+		it( 'should lock and unlock buffer', () => {
+			setData( doc, '<p>foo[]bar</p>' );
+
+			const spyLock = testUtils.sinon.spy( buffer, 'lock' );
+			const spyUnlock = testUtils.sinon.spy( buffer, 'unlock' );
+
+			editor.execute( 'input', {
+				text: ''
+			} );
+
+			expect( spyLock.calledOnce ).to.be.true;
+			expect( spyUnlock.calledOnce ).to.be.true;
+		} );
+
 		it( 'inserts text for collapsed range', () => {
 			setData( doc, '<p>foo[]</p>' );
 

+ 4 - 0
packages/ckeditor5-typing/tests/manual/20/1.html

@@ -0,0 +1,4 @@
+<div id="editor">
+	<h2>Heading 1</h2>
+	<p><em>This</em> is an <strong>editor</strong> instance.</p>
+</div>

+ 26 - 0
packages/ckeditor5-typing/tests/manual/20/1.js

@@ -0,0 +1,26 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Typing from '../../../src/typing';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	plugins: [ Enter, Typing, Paragraph, Undo, Bold, Italic, Heading ],
+	toolbar: [ 'headings', 'bold', 'italic', 'undo', 'redo' ]
+} )
+.then( editor => {
+	window.editor = editor;
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 15 - 0
packages/ckeditor5-typing/tests/manual/20/1.md

@@ -0,0 +1,15 @@
+## New undo step on changing selection ([#20](https://github.com/ckeditor/ckeditor5-typing/issues/20))
+
+*Every selection change should create a new undo step.*
+
+**Check**:
+
+1. Type "aaa" in one place.
+1. Move selection to another.
+1. Type "bbb".
+1. Move selection to another place.
+1. Type "ccc".
+1. Undo 3 times.
+
+**Expected**:
+3 undo steps were created. It is possible to undo 3 times, each time 3 letters from steps 5, 3, 1 are undone.

+ 4 - 0
packages/ckeditor5-typing/tests/manual/21/1.html

@@ -0,0 +1,4 @@
+<div id="editor">
+	<h2>Heading 1</h2>
+	<p><em>This</em> is an <strong>editor</strong> instance.</p>
+</div>

+ 26 - 0
packages/ckeditor5-typing/tests/manual/21/1.js

@@ -0,0 +1,26 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Typing from '../../../src/typing';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	plugins: [ Enter, Typing, Paragraph, Undo, Bold, Italic, Heading ],
+	toolbar: [ 'headings', 'bold', 'italic', 'undo', 'redo' ]
+} )
+.then( editor => {
+	window.editor = editor;
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 18 - 0
packages/ckeditor5-typing/tests/manual/21/1.md

@@ -0,0 +1,18 @@
+## New undo step on applying attribute ([#21](https://github.com/ckeditor/ckeditor5-typing/issues/21))
+
+*Every attribute change should create a new undo step.*
+
+**Check**:
+
+1. Type few letters.
+1. Press styling button (italic, bold, etc).
+1. Type few more letters.
+1. Press undo button.
+
+**Expected**:
+Only the letters typed in step 3 should be undone.
+
+5. Press undo button.
+
+**Expected**:
+Letters typed in step 1 should be undone.

+ 7 - 7
packages/ckeditor5-typing/tests/manual/40/1.md

@@ -1,11 +1,11 @@
-### Issue [#40](https://github.com/ckeditor/ckeditor5-typing/issues/40) manual test
+## Issue [#40](https://github.com/ckeditor/ckeditor5-typing/issues/40) manual test
 
- - select fragment of Heading and Paragraph,
- ```html
- <h2>Head{ing</h2>
- <p>Parag}raph</p>
- ```
- - delete selected text.
+ * select a fragment of the heading and paragraph,
+   ```html
+   <h2>Head{ing</h2>
+   <p>Parag}raph</p>
+   ```
+* delete the selected text.
 
 Expected result:
 ```html