Bläddra i källkod

Undo Feature initial commit.

Szymon Cofalik 9 år sedan
förälder
incheckning
f4674b5050

+ 89 - 0
packages/ckeditor5-undo/src/undocommand.js

@@ -0,0 +1,89 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Command from '../command/command.js';
+
+/**
+ * Undo command stores batches in itself and is able to and apply reverted versions of them on the document.
+ *
+ * undo.UndoCommand
+ */
+export default class UndoCommand extends Command {
+	/**
+	 * @see core.command.Command
+	 * @param {core.Editor} editor
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * Batches which are saved by the command. They can be reversed.
+		 *
+		 * @private
+		 * @member {Array.<core.treeModel.Batch>} core.command.UndoCommand#_batchStack
+		 */
+		this._batchStack = [];
+	}
+
+	/**
+	 * Stores a batch in the command. Stored batches can be then reverted.
+	 *
+	 * @param {core.teeModel.Batch} batch Batch to add.
+	 */
+	addBatch( batch ) {
+		this._batchStack.push( batch );
+	}
+
+	/**
+	 * Removes all batches from the stack.
+	 */
+	clearStack() {
+		this._batchStack = [];
+	}
+
+	/**
+	 * Checks whether this command should be enabled. Command is enabled when it has any batches in its stack.
+	 *
+	 * @private
+	 * @returns {Boolean}
+	 */
+	_checkEnabled() {
+		return this._batchStack.length > 0;
+	}
+
+	/**
+	 * Executes the command: reverts a {@link core.treeModel.Batch batch} added to the command's stack,
+	 * applies it on the document and removes the batch from the stack.
+	 *
+	 * Fires `undo` event with reverted batch as a parameter.
+	 *
+	 * @private
+	 * @param {Number} [batchIndex] If set, batch under the given index on the stack will be reverted and removed.
+	 * If not set, or invalid, the last added batch will be reverted and removed.
+	 */
+	_doExecute( batchIndex ) {
+		batchIndex = this._batchStack[ batchIndex ] ? batchIndex : this._batchStack.length - 1;
+
+		const undoBatch = this._batchStack.splice( batchIndex, 1 )[ 0 ];
+		const undoDeltas = undoBatch.deltas.slice();
+
+		undoDeltas.reverse();
+
+		for ( let undoDelta of undoDeltas ) {
+			const undoDeltaReversed = undoDelta.getReversed();
+			const updatedDeltas = this.editor.document.history.updateDelta( undoDeltaReversed );
+
+			for ( let delta of updatedDeltas ) {
+				for ( let operation of delta.operations ) {
+					this.editor.document.applyOperation( operation );
+				}
+			}
+		}
+
+		this.fire( 'undo', undoBatch );
+	}
+}

+ 71 - 0
packages/ckeditor5-undo/src/undofeature.js

@@ -0,0 +1,71 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Feature from '../feature.js';
+import UndoCommand from './undocommand.js';
+
+/**
+ * Undo feature.
+ *
+ * Undo features brings in possibility to undo and re-do changes done in Tree Model by deltas through Batch API.
+ */
+export default class UndoFeature extends Feature {
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * Undo command which manages undo {@link core.treeModel.Batch batches} stack (history).
+		 * Created and registered during {@link undo.UndoFeature#init feature initialization}.
+		 *
+		 * @private
+		 * @member {undo.UndoCommand} undo.UndoFeature#_undoCommand
+		 */
+		this._undoCommand = null;
+
+		/**
+		 * Undo command which manages redo {@link core.treeModel.Batch batches} stack (history).
+		 * Created and registered during {@link undo.UndoFeature#init feature initialization}.
+		 *
+		 * @private
+		 * @member {undo.UndoCommand} undo.UndoFeature#_redoCommand
+		 */
+		this._redoCommand = null;
+	}
+
+	/**
+	 * Initializes undo feature.
+	 */
+	init() {
+		// Create commands.
+		this._redoCommand = new UndoCommand( this.editor );
+		this._undoCommand = new UndoCommand( this.editor );
+
+		// Register command to the editor.
+		this.editor.commands.set( 'redo', this._redoCommand );
+		this.editor.commands.set( 'undo', this._undoCommand );
+
+		// Whenever new batch is created add it to undo history and clear redo history.
+		this.listenTo( this.editor.document, 'batch', ( evt, batch ) => {
+			this._undoCommand.addBatch( batch );
+			this._redoCommand.clearStack();
+		} );
+
+		// Whenever batch is reverted by undo command, add it to redo history.
+		this._undoCommand.listenTo( this._redoCommand, 'undo', ( evt, batch ) => {
+			this._undoCommand.addBatch( batch );
+		} );
+
+		// Whenever batch is reverted by redo command, add it to undo history.
+		this._redoCommand.listenTo( this._undoCommand, 'undo', ( evt, batch ) => {
+			this._redoCommand.addBatch( batch );
+		} );
+	}
+
+	destroy() {
+		this.stopListening();
+	}
+}

+ 267 - 0
packages/ckeditor5-undo/tests/undocommand.js

@@ -0,0 +1,267 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Editor from '/ckeditor5/editor.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+import UndoCommand from '/ckeditor5/undo/undocommand.js';
+
+let element, editor, doc, root, undo;
+
+beforeEach( () => {
+	element = document.createElement( 'div' );
+	document.body.appendChild( element );
+
+	editor = new Editor( element );
+	undo = new UndoCommand( editor );
+
+	doc = editor.document;
+	root = doc.createRoot( 'root' );
+} );
+
+afterEach( () => {
+	undo.destroy();
+} );
+
+describe( 'UndoCommand', () => {
+	describe( 'constructor', () => {
+		it( 'should create undo command with empty batch stack', () => {
+			expect( undo._batchStack.length ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'addBatch', () => {
+		it( 'should add a batch to command stack', () => {
+			const batch = doc.batch();
+			undo.addBatch( batch );
+
+			expect( undo._batchStack.length ).to.equal( 1 );
+			expect( undo._batchStack[ 0 ] ).to.equal( batch );
+		} );
+	} );
+
+	describe( 'clearStack', () => {
+		it( 'should remove all batches from the stack', () => {
+			undo.addBatch( doc.batch() );
+			undo.clearStack();
+
+			expect( undo._batchStack.length ).to.equal( 0 );
+		} );
+	} );
+
+	describe( '_checkEnabled', () => {
+		it( 'should return false if there are no batches in command stack', () => {
+			expect( undo._checkEnabled() ).to.be.false;
+		} );
+
+		it( 'should return true if there are batches in command stack', () => {
+			undo.addBatch( doc.batch() );
+
+			expect( undo._checkEnabled() ).to.be.true;
+		} );
+	} );
+
+	describe( '_doExecute', () => {
+		const p = pos => new Position( root, [ pos ] );
+		const r = ( a, b ) => new Range( p( a ), p( b ) );
+
+		let batch0, batch1, batch2, batch3;
+
+		beforeEach( () => {
+			/*
+			 [root]
+			 */
+			batch0 = doc.batch().insert( p( 0 ), 'foobar' );
+			/*
+			 [root]
+			 - f
+			 - o
+			 - o
+			 - b
+			 - a
+			 - r
+			 */
+			batch1 = doc.batch().setAttr( 'key', 'value', r( 2, 4 ) );
+			/*
+			 [root]
+			 - f
+			 - o
+			 - o {key: value}
+			 - b {key: value}
+			 - a
+			 - r
+			 */
+			batch2 = doc.batch().move( r( 1, 3 ), p( 6 ) );
+			/*
+			 [root]
+			 - f
+			 - b {key: value}
+			 - a
+			 - r
+			 - o
+			 - o {key: value}
+			 */
+			batch3 = doc.batch().wrap( r( 1, 4 ), 'p' );
+			/*
+			 [root]
+			 - f
+			 - [p]
+			 	- b {key: value}
+			 	- a
+			 	- r
+			 - o
+			 - o {key: value}
+			 */
+			batch2.move( r( 0, 1 ), p( 3 ) );
+			/*
+			 [root]
+			 - [p]
+			 	- b {key: value}
+			 	- a
+			 	- r
+			 - o
+			 - f
+			 - o {key: value}
+			 */
+
+			undo.addBatch( batch0 );
+			undo.addBatch( batch1 );
+			undo.addBatch( batch2 );
+			undo.addBatch( batch3 );
+		} );
+
+		it( 'should revert changes done by deltas from the batch that was most recently added to the command stack', () => {
+			undo._doExecute();
+
+			// Wrap is removed:
+			/*
+				[root]
+				- b {key: value}
+				- a
+				- r
+				- o
+				- f
+				- o {key: value}
+			 */
+
+			expect( Array.from( root._children._nodes.map( node => node.text ) ).join( '' ) ).to.equal( 'barofo' );
+			expect( root.getChild( 0 ).getAttribute( 'key' ) ).to.equal( 'value' );
+			expect( root.getChild( 5 ).getAttribute( 'key' ) ).to.equal( 'value' );
+
+			undo._doExecute();
+
+			// Two moves are removed:
+			/*
+				[root]
+				- f
+			 	- o
+			 	- o {key: value}
+				- b {key: value}
+				- a
+				- r
+			 */
+
+			expect( Array.from( root._children._nodes.map( node => node.text ) ).join( '' ) ).to.equal( 'foobar' );
+			expect( root.getChild( 2 ).getAttribute( 'key' ) ).to.equal( 'value' );
+			expect( root.getChild( 3 ).getAttribute( 'key' ) ).to.equal( 'value' );
+
+			undo._doExecute();
+
+			// Set attribute is undone:
+			/*
+				[root]
+				- f
+				- o
+				- o
+				- b
+				- a
+				- r
+			 */
+
+			expect( Array.from( root._children._nodes.map( node => node.text ) ).join( '' ) ).to.equal( 'foobar' );
+			expect( root.getChild( 2 ).hasAttribute( 'key' ) ).to.be.false;
+			expect( root.getChild( 3 ).hasAttribute( 'key' ) ).to.be.false;
+
+			undo._doExecute();
+
+			// Insert is undone:
+			/*
+				[root]
+			 */
+
+			expect( root.getChildCount() ).to.equal( 0 );
+		} );
+
+		it( 'should revert changes done by deltas from given batch, if parameter was passed (test: revert set attribute)', () => {
+			undo._doExecute( 1 );
+			// Remove attribute:
+			/*
+				[root]
+				- [p]
+					- b
+					- a
+					- r
+				- o
+				- f
+				- o
+			 */
+
+			expect( root.getChild( 0 ).name ).to.equal( 'p' );
+			expect( Array.from( root.getChild( 0 )._children._nodes.map( node => node.text ) ).join( '' ) ).to.equal( 'bar' );
+			expect( root.getChild( 0 ).getChild( 0 ).hasAttribute( 'key' ) ).to.be.false;
+			expect( root.getChild( 2 ).hasAttribute( 'key' ) ).to.be.false;
+			expect( root.getChild( 3 ).hasAttribute( 'key' ) ).to.be.false;
+		} );
+
+		it( 'should revert changes done by deltas from given batch, if parameter was passed (test: revert insert foobar)', () => {
+			undo._doExecute( 0 );
+			// Remove foobar:
+			/*
+			 [root]
+			 - [p]
+			 */
+
+			// The `P` element wasn't removed because it wasn`t added by undone batch.
+			// It would be perfect if the `P` got removed aswell because wrapping was on removed nodes.
+			// But this would need a lot of logic / hardcoded ifs or a post-fixer.
+			expect( root.getChildCount() ).to.equal( 1 );
+			expect( root.getChild( 0 ).name ).to.equal( 'p' );
+
+			undo._doExecute( 0 );
+			// Remove attributes.
+			// This does nothing in the `root` because attributes were set on nodes that already got removed.
+			// But those nodes should change in they graveyard and we can check them there.
+
+			expect( root.getChildCount() ).to.equal( 1 );
+			expect( root.getChild( 0 ).name ).to.equal( 'p' );
+
+			expect( doc.graveyard.getChildCount() ).to.equal( 6 );
+			// TODO: This one does not work because nodes are moved inside graveyard...
+			// TODO: Perfect situation would be if the nodes are moved to graveyard in the order they are in original tree.
+			// expect( Array.from( doc.graveyard._children._nodes.map( node => node.text ) ).join( '' ) ).to.equal( 'barofo' );
+			for ( let char of doc.graveyard._children ) {
+				expect( char.hasAttribute( 'key' ) ).to.be.false;
+			}
+
+			// Let's undo wrapping. This should leave us with empty root.
+			undo._doExecute( 1 );
+			expect( root.getChildCount() ).to.equal( 0 );
+		} );
+
+		it( 'should fire undo event with the undone batch', () => {
+			const batch = doc.batch();
+			const spy = sinon.spy();
+
+			undo.on( 'undo', spy );
+
+			undo._doExecute();
+
+			expect( spy.calledOnce ).to.be.true;
+			expect( spy.calledWith( batch ) );
+		} );
+	} );
+} );

+ 88 - 0
packages/ckeditor5-undo/tests/undofeature.js

@@ -0,0 +1,88 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Editor from '/ckeditor5/editor.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import UndoFeature from '/ckeditor5/undo/undofeature.js';
+
+let element, editor, undo, batch, doc, root;
+
+beforeEach( () => {
+	element = document.createElement( 'div' );
+	document.body.appendChild( element );
+
+	editor = new Editor( element );
+	undo = new UndoFeature( editor );
+	undo.init();
+
+	doc = editor.document;
+	batch = doc.batch();
+	root = doc.createRoot( 'root' );
+} );
+
+afterEach( () => {
+	undo.destroy();
+} );
+
+describe( 'UndoFeature', () => {
+	it( 'should register undo command and redo command', () => {
+		expect( editor.commands.get( 'undo' ) ).to.equal( undo._undoCommand );
+		expect( editor.commands.get( 'redo' ) ).to.equal( undo._redoCommand );
+	} );
+
+	it( 'should add a batch to undo command whenever a new batch is applied to the document', () => {
+		sinon.spy( undo._undoCommand, 'addBatch' );
+
+		expect( undo._undoCommand.addBatch.called ).to.be.false;
+
+		batch.insert( new Position( root, [ 0 ] ), 'foobar' );
+
+		expect( undo._undoCommand.addBatch.calledOnce ).to.be.true;
+
+		batch.insert( new Position( root, [ 0 ] ), 'foobar' );
+
+		expect( undo._undoCommand.addBatch.calledOnce ).to.be.true;
+	} );
+
+	it( 'should add a batch to redo command whenever a batch is undone by undo command', () => {
+		batch.insert( new Position( root, [ 0 ] ), 'foobar' );
+
+		sinon.spy( undo._redoCommand, 'addBatch' );
+
+		undo._undoCommand.fire( 'undo', batch );
+
+		expect( undo._redoCommand.addBatch.calledOnce ).to.be.true;
+		expect( undo._redoCommand.addBatch.calledWith( batch ) ).to.be.true;
+	} );
+
+	it( 'should add a batch to undo command whenever a batch is redone by redo command', () => {
+		batch.insert( new Position( root, [ 0 ] ), 'foobar' );
+
+		sinon.spy( undo._undoCommand, 'addBatch' );
+
+		undo._redoCommand.fire( 'undo', batch );
+
+		expect( undo._undoCommand.addBatch.calledOnce ).to.be.true;
+		expect( undo._undoCommand.addBatch.calledWith( batch ) ).to.be.true;
+	} );
+
+	it( 'should clear redo command stack whenever a new batch is applied to the document', () => {
+		sinon.spy( undo._redoCommand, 'clearStack' );
+
+		batch.insert( new Position( root, [ 0 ] ), 'foobar' );
+
+		expect( undo._redoCommand.clearStack.calledOnce ).to.be.true;
+	} );
+
+	it( 'should stop listening when destroyed', () => {
+		sinon.spy( undo, 'stopListening' );
+
+		undo.destroy();
+
+		expect( undo.stopListening.called ).to.be.true;
+	} );
+} );