// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. interface UndoCommand { checkpoint: number undo: (value?: any) => Promise redo: () => Promise description?: string groupId?: string value?: any } // // General-purpose undo manager // class UndoManager { onStateDidChange?: () => void private commands: UndoCommand[] = [] private index = -1 private limit = 0 private isExecuting = false get currentCheckpoint() { if (this.index < 0) { return 0 } return this.commands[this.index].checkpoint } get undoDescription(): string | undefined { const command = this.commands[this.index] if (!command) { return undefined } return command.description } get redoDescription(): string | undefined { const command = this.commands[this.index + 1] if (!command) { return undefined } return command.description } private async execute(command: UndoCommand, action: 'undo' | 'redo') { if (!command || typeof command[action] !== 'function') { return this } this.isExecuting = true if (action === 'redo') { command.value = await command[action]() } else { await command[action](command.value) } this.isExecuting = false return this } async perform( redo: () => Promise, undo: (value?: any) => Promise, description?: string, groupId?: string, isDiscardable = false, ): Promise { const value = await redo() this.registerUndo({undo, redo}, description, groupId, value, isDiscardable) return value } registerUndo( command: { undo: (value?: any) => Promise, redo: () => Promise }, description?: string, groupId?: string, value?: any, isDiscardable = false, ): UndoManager { if (this.isExecuting) { return this } // If we are here after having called undo, invalidate items higher on the stack this.commands.splice(this.index + 1, this.commands.length - this.index) let checkpoint: number if (isDiscardable) { checkpoint = this.commands.length > 1 ? this.commands[this.commands.length - 1].checkpoint : 0 } else { checkpoint = Date.now() } const internalCommand = { checkpoint, undo: command.undo, redo: command.redo, description, groupId, value, } this.commands.push(internalCommand) // If limit is set, remove items from the start if (this.limit && this.commands.length > this.limit) { this.commands = this.commands.splice( 0, this.commands.length - this.limit, ) } // Set the current index to the end this.index = this.commands.length - 1 if (this.onStateDidChange) { this.onStateDidChange() } return this } async undo() { if (this.isExecuting) { return this } let command = this.commands[this.index] if (!command) { return this } const currentGroupId = command.groupId if (currentGroupId) { do { // eslint-disable-next-line no-await-in-loop await this.execute(command, 'undo') this.index -= 1 command = this.commands[this.index] } while (this.index >= 0 && currentGroupId === command.groupId) } else { await this.execute(command, 'undo') this.index -= 1 } if (this.onStateDidChange) { this.onStateDidChange() } return this } async redo() { if (this.isExecuting) { return this } let command = this.commands[this.index + 1] if (!command) { return this } const currentGroupId = command.groupId if (currentGroupId) { do { // eslint-disable-next-line no-await-in-loop await this.execute(command, 'redo') this.index += 1 command = this.commands[this.index + 1] } while (this.index < this.commands.length - 1 && currentGroupId === command.groupId) } else { await this.execute(command, 'redo') this.index += 1 } if (this.onStateDidChange) { this.onStateDidChange() } return this } clear() { const prevSize = this.commands.length this.commands = [] this.index = -1 if (this.onStateDidChange && prevSize > 0) { this.onStateDidChange() } } get canUndo() { return this.index !== -1 } get canRedo() { return this.index < this.commands.length - 1 } } const undoManager = new UndoManager() export default undoManager