@reactodia/workspace
Version:
Reactodia Workspace -- library for visual interaction with graphs in a form of a diagram.
365 lines (334 loc) • 10.6 kB
text/typescript
import { EventSource, Events } from '../coreUtils/events';
import type { TranslatedText } from '../coreUtils/i18n';
/**
* Represents an atomic change to the state tracked by the command history,
* which could be performed and reverted later if needed.
*
* @category Core
* @see {@link CommandHistory}
*/
export interface Command {
/**
* Command title to display in the UI.
*/
readonly title?: TranslatedText | string;
/**
* Performs the command action.
*
* @returns Inverse command to reverse changes done by the action.
*/
invoke(): Command;
}
/**
* Command action which can be executed for its effect.
*
* @returns Inverse command
*/
export type CommandAction = () => Command;
/**
* Utility functions to create commands of different kinds.
*
* @category Core
*/
export namespace Command {
/**
* Creates a basic reversible command.
*
* **Example**:
* ```ts
* function changeFoo(store: FooStore, foo: Foo): Command {
* return Command.create('Set foo', () => {
* const previous = store.foo;
* store.setFoo(foo);
* return changeFoo(store, previous);
* });
* }
* ```
*/
export function create(
title: TranslatedText | string,
action: CommandAction
): Command {
return new SimpleCommand(title, action);
}
/**
* Creates a command which only does its action in one direction,
* i.e. performs an effect on the execution but skips it when being undone
* or vice-versa.
*/
export function effect(
title: TranslatedText | string,
body: () => void
): Command {
return new EffectCommand(title, body);
}
/**
* Creates a command composed of multiple other commands.
*
* When executed, sub-commands in be executed in the same order,
* and the inverse command will use a reversed order of the sub-command inverses.
*/
export function compound(
title: TranslatedText | string | undefined,
commands: ReadonlyArray<Command>
): Command {
return new CompoundCommand(title, commands);
}
}
class SimpleCommand implements Command {
constructor(
readonly title: TranslatedText | string,
readonly invoke: CommandAction
) {}
}
class EffectCommand implements Command {
private readonly skip: Command;
constructor(
readonly title: TranslatedText | string,
private readonly body: () => void
) {
this.skip = new SkippedEffect(title, this);
}
invoke(): Command {
const {body} = this;
body();
return this.skip;
}
}
class SkippedEffect implements Command {
constructor(
readonly title: TranslatedText | string,
private readonly effect: EffectCommand
) {}
invoke(): Command {
return this.effect;
}
}
class CompoundCommand {
constructor(
readonly title: TranslatedText | string | undefined,
private readonly commands: ReadonlyArray<Command>
) {}
invoke(): Command {
const inverses: Command[] = [];
for (const command of this.commands) {
inverses.push(command.invoke());
}
inverses.reverse();
return new CompoundCommand(this.title, inverses);
}
}
/**
* Event data for {@link CommandHistory} events.
*
* @see {@link CommandHistory}
*/
export interface CommandHistoryEvents {
/**
* Triggered when command history changes after a command is executed,
* undone, redone or the history is cleared.
*/
historyChanged: CommandHistoryChangedEvent;
}
/**
* Event data for command history changed event.
*/
export interface CommandHistoryChangedEvent {
/**
* `true` if there are any changes which could be reverted.
*/
readonly hasChanges: boolean;
}
/**
* Provides an undo/redo mechanism to the diagram model and components.
*
* To make it possible to undo/redo the changes, all state modifications
* are organized in a form of atomic commands. These commands can be
* combined into a command batch to be presented to user as a single
* meaningful operation.
*
* @category Core
*/
export interface CommandHistory {
/**
* Events for the command history.
*/
readonly events: Events<CommandHistoryEvents>;
/**
* Current stack of operation which could be undone (reverted).
*
* Latest executed (or redone) commands are put at the end of the stack.
*/
readonly undoStack: ReadonlyArray<Command>;
/**
* Current stack of operation which could be redone (performed again after being undone).
*
* Latest undone commands are put at the end of the stack.
*/
readonly redoStack: ReadonlyArray<Command>;
/**
* Clears undo and redo stacks of commands.
*/
reset(): void;
/**
* Undoes (reverts) the latest executed command and puts it into redo stack.
*
* If undo stack is empty, does nothing.
*/
undo(): void;
/**
* Redoes (performs again) the latest undone command.
*
* If redo stack is empty, does nothing.
*/
redo(): void;
/**
* Executes the specified command and puts its inverse into undo stack.
*/
execute(command: Command): void;
/**
* Puts the inverse command directly into undo stack.
*/
registerToUndo(command: Command): void;
/**
* Starts a new command batch which will be active either stored or discarded.
*
* When a batch is active, all executed or registered to undo commands will be put
* into the batch instead of the undo stack, so it could be undo as whole later.
*
* Starting a new batch when there is an active command batch already
* causes the new batch to become nested, which allows to use operations creating
* command batches as part of a larger operation having its own top-level batch.
*/
startBatch(title?: TranslatedText | string): CommandBatch;
}
/**
* Provide the means to store or discard command batch.
*
* @category Core
* @see {@link CommandHistory.startBatch}
*/
export interface CommandBatch {
/**
* Command history which owns this batch.
*/
readonly history: CommandHistory;
/**
* Stores the batch, combining the nested command sequence
* into a single revertible command.
*/
store(): void;
/**
* Discards the batch, throwing away all nested commands, so they cannot be undone.
*
* This is useful when performing state changes on temporary items to
* avoid being able to revert it.
*/
discard(): void;
}
interface InMemoryBatch extends CommandBatch {
readonly _title: TranslatedText | string | undefined;
readonly _inverses: Command[];
}
/**
* Implements command history which stores all commands in memory.
*
* @category Core
*/
export class InMemoryHistory implements CommandHistory {
private readonly source = new EventSource<CommandHistoryEvents>();
readonly events: Events<CommandHistoryEvents> = this.source;
private readonly _undoStack: Command[] = [];
private readonly _redoStack: Command[] = [];
private readonly batches: InMemoryBatch[] = [];
get undoStack(): ReadonlyArray<Command> {
return this._undoStack;
}
get redoStack(): ReadonlyArray<Command> {
return this._redoStack;
}
private hasChanges(): boolean {
return this._undoStack.length > 0;
}
reset() {
this._undoStack.length = 0;
this._redoStack.length = 0;
this.source.trigger('historyChanged', {hasChanges: this.hasChanges()});
}
undo() {
const command = this._undoStack.pop();
if (command) {
const inverse = command.invoke();
this._redoStack.push(inverse);
this.source.trigger('historyChanged', {hasChanges: this.hasChanges()});
}
}
redo() {
const command = this._redoStack.pop();
if (command) {
const inverse = command.invoke();
this._undoStack.push(inverse);
this.source.trigger('historyChanged', {hasChanges: this.hasChanges()});
}
}
execute(command: Command) {
const inverse = command.invoke();
this.registerToUndo(inverse);
}
registerToUndo(command: Command) {
const batch = this.topBatch();
if (batch) {
batch._inverses.push(command);
} else {
this._undoStack.push(command);
this._redoStack.length = 0;
this.source.trigger('historyChanged', {hasChanges: this.hasChanges()});
}
}
private topBatch(): InMemoryBatch | undefined {
return this.batches.length === 0 ? undefined : this.batches[this.batches.length - 1];
}
startBatch(title?: TranslatedText | string): CommandBatch {
const batch: InMemoryBatch = {
_title: title,
_inverses: [],
history: this,
store: () => {
if (!this.batches.includes(batch)) {
console.warn('Failed to find batch to store (already stored or discarded?)', batch);
return;
}
while (this.batches.length > 0) {
const other = this.batches.pop()!;
if (other !== batch) {
console.warn('Storing other unclosed batch on top', other);
}
if (other._inverses.length > 0) {
const commands = [...other._inverses].reverse();
this.registerToUndo(Command.compound(batch._title, commands));
}
if (other === batch) {
break;
}
}
},
discard: () => {
if (!this.batches.includes(batch)) {
console.warn('Failed to find batch to store (already stored or discarded?)');
return;
}
while (this.batches.length > 0) {
const other = this.batches.pop();
if (other !== batch) {
console.warn('Discarding other unclosed batch on top', other);
}
if (other === batch) {
break;
}
}
},
};
this.batches.push(batch);
return batch;
}
}