Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Undo Feature. #1

Merged
merged 13 commits into from
May 13, 2016
80 changes: 80 additions & 0 deletions src/undo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @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.
*
* @memberOf undo
*/
export default class Undo extends Feature {
constructor( editor ) {
super( editor );

/**
* Undo command which manages undo {@link engine.treeModel.Batch batches} stack (history).
* Created and registered during {@link undo.Undo#init feature initialization}.
*
* @private
* @member {undo.UndoCommand} undo.Undo#_undoCommand
*/
this._undoCommand = null;

/**
* Undo command which manages redo {@link engine.treeModel.Batch batches} stack (history).
* Created and registered during {@link undo.Undo#init feature initialization}.
*
* @private
* @member {undo.UndoCommand} undo.Undo#_redoCommand
*/
this._redoCommand = null;

/**
* Keeps track of which batch has already been added to undo manager.
*
* @private
* @member {WeakSet.<engine.treeModel.Batch>} undo.Undo#_batchRegistry
*/
this._batchRegistry = new WeakSet();
}

/**
* @inheritDoc
*/
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 );

this.listenTo( this.editor.document, 'change', ( evt, type, changes, batch ) => {
// Whenever a new batch is created add it to the undo history and clear redo history.
if ( batch && !this._batchRegistry.has( batch ) ) {
this._batchRegistry.add( batch );
this._undoCommand.addBatch( batch );
this._redoCommand.clearStack();
}
} );

// Whenever batch is reverted by undo command, add it to redo history.
this.listenTo( this._redoCommand, 'revert', ( evt, batch ) => {
this._undoCommand.addBatch( batch );
} );

// Whenever batch is reverted by redo command, add it to undo history.
this.listenTo( this._undoCommand, 'revert', ( evt, batch ) => {
this._redoCommand.addBatch( batch );
} );
}
}
211 changes: 211 additions & 0 deletions src/undocommand.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* @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.
*
* @memberOf undo
*/
export default class UndoCommand extends Command {
constructor( editor ) {
super( editor );

/**
* Items that are pairs of:
*
* * batches which are saved by the command and,
* * model selection state at the moment of saving the batch.
*
* @private
* @member {Array} undo.UndoCommand#_items
*/
this._items = [];
}

/**
* Stores a batch in the command. Stored batches can be then reverted.
*
* @param {engine.treeModel.Batch} batch Batch to add.
*/
addBatch( batch ) {
const selection = {
ranges: Array.from( this.editor.document.selection.getRanges() ),
isBackward: this.editor.document.selection.isBackward
};

this._items.push( { batch, selection } );
this.refreshState();
}

/**
* Removes all batches from the stack.
*/
clearStack() {
this._items = [];
this.refreshState();
}

/**
* @inheritDoc
*/
_checkEnabled() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misses docs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, it's inherited from Command. Hmm... so maybe, to avoid confusion, we could use @inheritDoc in such cases. Then it will be clear that the doc comes from the parent.

return this._items.length > 0;
}

/**
* Executes the command: reverts a {@link engine.treeModel.Batch batch} added to the command's stack,
* applies it on the document and removes the batch from the stack.
*
* @protected
* @fires undo.undoCommand#event:revert
* @param {engine.treeModel.Batch} [batch] If set, batch that should be undone. If not set, the last added batch will be undone.
*/
_doExecute( batch ) {
let batchIndex;

// If batch is not given, set `batchIndex` to the last index in command stack.
// If it is given, find it on the stack.
if ( !batch ) {
batchIndex = this._items.length - 1;
} else {
batchIndex = this._items.findIndex( item => item.batch == batch );
}

const undoItem = this._items.splice( batchIndex, 1 )[ 0 ];

// Get the batch to undo.
const undoBatch = undoItem.batch;
const undoDeltas = undoBatch.deltas.slice();
// Deltas have to be applied in reverse order, so if batch did A B C, it has to do reversed C, reversed B, reversed A.
undoDeltas.reverse();

// Reverse the deltas from the batch, transform them, apply them.
for ( let undoDelta of undoDeltas ) {
const undoDeltaReversed = undoDelta.getReversed();
const updatedDeltas = this.editor.document.history.getTransformedDelta( undoDeltaReversed );

for ( let delta of updatedDeltas ) {
for ( let operation of delta.operations ) {
this.editor.document.applyOperation( operation );
}
}
}

// Get the selection state stored with this batch.
const selectionState = undoItem.selection;

// Take all selection ranges that were stored with undone batch.
const ranges = selectionState.ranges;

// The ranges will be transformed by deltas from history that took place
// after the selection got stored.
const deltas = this.editor.document.history.getDeltas( undoBatch.deltas[ 0 ].baseVersion );

// This will keep the transformed ranges.
const transformedRanges = [];

for ( let originalRange of ranges ) {
// We create `transformed` array. At the beginning it will have only the original range.
// During transformation the original range will change or even break into smaller ranges.
// After the range is broken into two ranges, we have to transform both of those ranges separately.
// For that reason, we keep all transformed ranges in one array and operate on it.
let transformed = [ originalRange ];

for ( let delta of deltas ) {
for ( let operation of delta.operations ) {
// We look through all operations from all deltas.

for ( let t = 0; t < transformed.length; t++ ) {
// We transform every range by every operation.
// We keep current state of transformation in `transformed` array and update it.
let result;

switch ( operation.type ) {
case 'insert':
result = transformed[ t ].getTransformedByInsertion(
operation.position,
operation.nodeList.length,
true
);
break;

case 'move':
case 'remove':
case 'reinsert':
result = transformed[ t ].getTransformedByMove(
operation.sourcePosition,
operation.targetPosition,
operation.howMany,
true
);
break;
}

// If we have a transformation result, we substitute it in `transformed` array with
// the range that got transformed. Keep in mind that the result is an array
// and may contain multiple ranges.
if ( result ) {
transformed.splice( t, 1, ...result );

// Fix iterator.
t = t + result.length - 1;
}
}
}
}

// After `originalRange` got transformed, we have an array of ranges. Some of those
// ranges may be "touching" -- they can be next to each other and could be merged.
// Let's do this. First, we have to sort those ranges because they don't have to be
// in an order.
transformed.sort( ( a, b ) => a.start.isBefore( b.start ) ? -1 : 1 );

// Then we check if two consecutive ranges are touching. We can do it pair by pair
// in one dimensional loop because ranges are sorted.
for ( let i = 1 ; i < transformed.length; i++ ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just wanted to leave some comment in the middle of this function, so it looks like we've read and understood that code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fairly simple, I can explain ...

let a = transformed[ i - 1 ];
let b = transformed[ i ];

if ( a.end.isTouching( b.start ) ) {
a.end = b.end;
transformed.splice( i, 1 );
i--;
}
}

// For each `originalRange` from `ranges`, we take only one transformed range.
// This is because we want to prevent situation where single-range selection
// got transformed to mulit-range selection. We will take the first range that
// is not in the graveyard.
const transformedRange = transformed.find(
( range ) => range.start.root != this.editor.document.graveyard
);

if ( transformedRange ) {
transformedRanges.push( transformedRange );
}
}

// `transformedRanges` may be empty if all ranges ended up in graveyard.
// If that is the case, do not restore selection.
if ( transformedRanges.length ) {
this.editor.document.selection.setRanges( transformedRanges, selectionState.isBackward );
}

this.refreshState();
this.fire( 'revert', undoBatch );
}
}

/**
* Fired after `UndoCommand` reverts a batch.
*
* @event undo.UndoCommand#revert
* @param {engine.treeModel.Batch} undoBatch The batch instance that got reverted.
*/
Loading