208 lines
5.8 KiB
TypeScript
208 lines
5.8 KiB
TypeScript
/**
|
|
* An action that can (possibly) be done and undone.
|
|
*/
|
|
export class Action {
|
|
readonly run: () => void;
|
|
readonly undo: () => boolean;
|
|
readonly isUndoable: () => boolean;
|
|
|
|
|
|
/**
|
|
* Constructs a new action.
|
|
*
|
|
* @param doe the action to perform
|
|
* @param undo the inverse of the action to perform, returning whether the action has been undone
|
|
* @param isUndoable whether the action is currently undoable
|
|
*/
|
|
constructor(doe: () => void, undo: () => boolean, isUndoable: () => boolean) {
|
|
this.run = doe;
|
|
this.undo = undo;
|
|
this.isUndoable = isUndoable;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A sequence of actions that can be done and undone.
|
|
*/
|
|
export class ActionSequence extends Action {
|
|
private readonly actions: Action[] = [];
|
|
private _isUndoable: boolean = true;
|
|
|
|
|
|
/**
|
|
* Constructs a new action sequence.
|
|
*/
|
|
constructor() {
|
|
super(() => this._run(), () => this._undo(), () => this._isUndoable);
|
|
}
|
|
|
|
|
|
/**
|
|
* Adds an action to the sequence.
|
|
*
|
|
* If the given action is not undoable, then this action sequence is no longer undoable.
|
|
*
|
|
* @param action the action to add
|
|
*/
|
|
addAction(action: Action): void {
|
|
if (!action.isUndoable())
|
|
this._isUndoable = false;
|
|
|
|
this.actions.push(action);
|
|
}
|
|
|
|
/**
|
|
* Returns `true` if and only if this action sequence does not contain any actions.
|
|
*
|
|
* @returns `true` if and only if this action sequence does not contain any actions
|
|
*/
|
|
isEmpty(): boolean {
|
|
return this.actions.length === 0;
|
|
}
|
|
|
|
|
|
/**
|
|
* Performs all actions in the sequence in order.
|
|
*/
|
|
private _run(): void {
|
|
this.actions.forEach(it => it.run());
|
|
}
|
|
|
|
/**
|
|
* Undoes all actions in the sequence in reverse order.
|
|
*/
|
|
private _undo(): boolean {
|
|
if (this._isUndoable)
|
|
[...this.actions].reverse().forEach(it => it.undo());
|
|
|
|
return this._isUndoable;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A history of actions that can be tracked granularly and can be undone rapidly.
|
|
*/
|
|
export class ActionHistory {
|
|
private readonly sequences: ActionSequence[] = [];
|
|
private sequenceIndex = -1;
|
|
private depth: number = 0;
|
|
|
|
|
|
/**
|
|
* The sequence at the current index.
|
|
*
|
|
* @private
|
|
*/
|
|
private get currentSequence(): ActionSequence | undefined {
|
|
return this.sequences[this.sequenceIndex];
|
|
}
|
|
|
|
/**
|
|
* `true` if and only if there is a sequence of actions that has not been committed yet.
|
|
*/
|
|
get hasUncommittedSequence(): boolean {
|
|
return this.depth > 0;
|
|
}
|
|
|
|
|
|
/**
|
|
* Starts a new sequence of actions, or continues the current uncommitted sequence if there is one.
|
|
*/
|
|
startSequence(): void {
|
|
if (this.depth === 0) {
|
|
this.sequenceIndex++;
|
|
this.sequences.length = this.sequenceIndex;
|
|
this.sequences.push(new ActionSequence());
|
|
}
|
|
this.depth++;
|
|
}
|
|
|
|
/**
|
|
* Adds an action to the current uncommitted sequence.
|
|
*
|
|
* @param action the action to add
|
|
*/
|
|
addAction(action: Action): void {
|
|
if (!this.hasUncommittedSequence)
|
|
throw new Error("Cannot add action if there is no uncommitted sequence.");
|
|
|
|
this.currentSequence!.addAction(action);
|
|
}
|
|
|
|
/**
|
|
* Commits the last sequence of actions or removes it if no actions were added, but only if the number of calls to
|
|
* this function equals the number of calls to `#startSequence`.
|
|
*/
|
|
commitSequence(): void {
|
|
if (!this.hasUncommittedSequence)
|
|
throw new Error("Cannot commit sequence if there is no uncommitted sequence.");
|
|
|
|
this.depth--;
|
|
if (this.depth === 0 && this.currentSequence!.isEmpty()) {
|
|
this.sequences.pop();
|
|
this.sequenceIndex--;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns `true` if and only if there is an action sequence that can be undone.
|
|
*
|
|
* @returns `true` if and only if there is an action sequence that can be undone
|
|
*/
|
|
canUndo(): boolean {
|
|
return this.sequenceIndex >= 1;
|
|
}
|
|
|
|
/**
|
|
* Undoes up to and including the last `amount` sequences in this history, stopping prematurely if any of the
|
|
* actions cannot be undone.
|
|
*
|
|
* @param amount the maximum amount of sequences to undo
|
|
* @returns the actual amount of sequences that have been undone
|
|
*/
|
|
undo(amount: number = this.sequenceIndex + 1): number {
|
|
if (this.hasUncommittedSequence)
|
|
throw new Error("Cannot undo sequences while there is an uncommitted sequence.");
|
|
|
|
amount = Math.min(amount, this.sequenceIndex + 1);
|
|
let i;
|
|
for (i = 0; i < amount; i++) {
|
|
const sequence = this.currentSequence!;
|
|
if (!sequence.isUndoable())
|
|
break;
|
|
|
|
sequence.undo();
|
|
this.sequenceIndex--;
|
|
}
|
|
return i;
|
|
}
|
|
|
|
/**
|
|
* Returns `true` if and only if there is an action sequence that can be redone.
|
|
*
|
|
* @returns `true` if and only if there is an action sequence that can be redone
|
|
*/
|
|
canRedo(): boolean {
|
|
return this.sequenceIndex < this.sequences.length - 1;
|
|
}
|
|
|
|
/**
|
|
* Redoes up to the last `amount` of sequences that were undone.
|
|
*
|
|
* @param amount the maximum number of sequences to redo
|
|
* @returns the actual amount of sequences that have been redone
|
|
*/
|
|
redo(amount: number = this.sequences.length - (this.sequenceIndex + 1)): number {
|
|
if (this.hasUncommittedSequence)
|
|
throw new Error("Cannot redo sequences while there is an uncommitted sequence.");
|
|
|
|
amount = Math.min(amount, this.sequences.length - (this.sequenceIndex + 1));
|
|
for (let i = 0; i < amount; i++) {
|
|
this.sequenceIndex++;
|
|
this.currentSequence!.run();
|
|
}
|
|
return amount;
|
|
}
|
|
}
|