UNPKG

@eclipse-emfcloud/model-manager

Version:

Command-based model editing with undo/redo.

363 lines (318 loc) 11.8 kB
// ***************************************************************************** // Copyright (C) 2023-2024 STMicroelectronics. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: MIT License which is // available at https://opensource.org/licenses/MIT. // // SPDX-License-Identifier: EPL-2.0 OR MIT // ***************************************************************************** import type { Command, CommandResult, CoreCommandStack, EditingContext, } from '../core'; /** * Function type for the `onCommandStackChanged` call-back of a `CommandStackSubscription`. * * @callback EditingContextChangedCallback * * @template K the type of key with which a command stack associates models * * @param eventType the type of change that occurred in the command stack * @param [command] the command, if any, related to the change. Only the `'flushed'` event does not include a `command` */ export type CommandStackChangedCallback<K> = ( eventType: 'executed' | 'undone' | 'redone' | 'flushed', command?: Command<K> ) => void; /** * Function type for the `onDirtyStateChanged` call-back of a `CommandStackSubscription`. * * @callback DirtyStateChangedCallback * * @template K the type of key with which a command stack associates models * * @param dirtyStateChanges for each model ID whose dirty state has changed, whether it is now dirty or not */ export type DirtyStateChangedCallback<K> = ( dirtyStateChanges: Map<K, boolean> ) => void; /** * A subscription to changes in the state of a {@link CommandStack}. * When the subscription is no longer needed, it should be `close()`d * to avoid the overhead of continuing to notify it. * * @template K the type of key with which a command stack associates models */ export interface CommandStackSubscription<K> { /** * A function to call to notify the client of editing context changes. */ onCommandStackChanged?: CommandStackChangedCallback<K>; /** * A function to call to notify the client of dirty state changes. */ onDirtyStateChanged?: DirtyStateChangedCallback<K>; /** * Stop receiving notifications of model changes. */ close(): void; } /** * Options for configuration of the command stacks obtains from a {@link ModelManager}. */ export type CommandStackOptions = { /** * Whether the command stack keeps a history of commands for undo/redo. * * @default true */ keepHistory?: boolean; }; /** Default values of all command-stack options. */ const commandStackOptionsDefaults: Required<CommandStackOptions> = { keepHistory: true, }; /** * Complete the incoming `options` with defaults for any not specified. * * @param [options] the options, if any, to complete * @returns the completed `options`, filled out with defaults where necessary */ const defaultCommandStackOptions = <T extends CommandStackOptions>( options: T ): Required<T> => ({ ...commandStackOptionsDefaults, ...options } as Required<T>); /** * An ordered history of the execution of commands on the models managed by a `ModelManager`. * * @template K the type of model ID used by the Model Manager */ export interface CommandStack<K = string> { /** * Execute a command on the stack. * If successful, that command becomes the next available to {@link undo}. * * The result is a mapping of commands to the changes that they applied to their target models, * which accounts for the case that the `command` is a compound. * * @param command a command to execute * @returns a description of the changes performed by the `command`, if available * @throws if the `command` is not executable */ execute(command: Command<K>): CommandResult<K>; /** * Execute a command and append it to the command most recently executed. * If successful, that command is integrated into the appended command for {@link undo} and {@link redo}. * * As for {@link execute}, the result is a mapping of commands to the changes that they applied to their * target models, which accounts for the case that the `command` is a compound. * * @param command a command to execute and append onto the last executed command * @returns a description of the changes performed by the `command`, if available * @throws if the `command` is not executable or if there has been no command * executed to append to */ executeAndAppend(command: Command<K>): CommandResult<K>; /** * Undo the command most recently executed/redone. * If successful, that command becomes the next available to {@link redo}. * * @returns a description of the changes performed by undo, if available * @throws if there is no command to undo or if that command is not undoable * * @see {@link getUndoCommand} * @see {@link canUndo} */ undo(): CommandResult<K>; /** * Redo the command most recently undone. * If successful, that command becomes the next available to {@link undo}. * * @returns a description of the changes performed by redo, if available * @throws if there is no command to redo or if that command is not redoable * * @see {@link getRedoCommand} * @see {@link canRedo} */ redo(): CommandResult<K>; /** * Query whether a `command` can be executed. * * @param command a command to be executed * @returns whether the `command` can be executed * @see {@link execute} */ canExecute(command: Command<K>): Promise<boolean>; /** * Query whether there is some command available to undo that can be undone. * * @returns whether the stack has a command to undo and that command is undoable * @see {@link getUndoCommand} */ canUndo(): Promise<boolean>; /** * Query whether there is some command available to redo that can be redone. * * @returns whether the editing `context` has a command to redo and that command is redoable * @see {@link getRedoCommand} */ canRedo(): Promise<boolean>; /** * Query what is the next command that would be undone by a request to {@link undo}. * * @returns the next undoable command, or `undefined` if none */ getUndoCommand(): Command<K> | undefined; /** * Query what is the next command that would be redone by a request to {@link redo}. * * @returns the next redoable command, or `undefined` if none */ getRedoCommand(): Command<K> | undefined; /** * Flush the stack. * All commands in the undo and redo history are removed and are returned * in the (temporal) order in which they were originally executed. * * @return the commands that were purged from the stack, or an empty array if none */ flush(): Command<K>[]; /** * Mark the the current top of the stack as the point where the models that * are edited in this context have been saved. * This should be done by a client that has saved those models to * persistent storage. * * @see {@link isDirty} * @see {@link getDirtyModels} */ markSaved(): void; /** * Queries whether any commands have been executed, undone, or redone since * the last {@link markSaved save}. * * @returns whether I am dirty * * @see {@link markSaved} * @see {@link getDirtyModelIds} */ isDirty(): boolean; /** * Query the IDs of models that have been modified by execution, undo, and redo * of commands since the last {@link markSaved save}. * This may include models that are assumed to be dirty because they were * modified at the time this `context` was last flushed. * * @see {@link markSaved} * @see {@link isDirty} */ getDirtyModelIds(): K[]; /** * Create a subscription to changes occurring in the command stack. * Each call creates a new subscription that, when no longer needed, {@link CommandStackSubscription.close should be closed} separately. * * @returns the command-stack subscription */ subscribe(): CommandStackSubscription<K>; /** * Get the core stack underlying this command stack, for access to its advanced capabilities. * * @returns the core stack */ getCoreCommandStack(): CoreCommandStack<K>; } /** * The implementation of the command stack wraps a {@link CoreCommandStack} and adds a private * {@link EditingContext} to all operation delegation. * * @template K the type of model ID used by the Model Manager */ export class CommandStackImpl<K = string> implements CommandStack<K> { /** The editing context that represent me in the shared delegate stack. */ private readonly editingContext: EditingContext; /** My configuration options, defaulted where necessary to provide them all. */ private readonly options: Required<CommandStackOptions>; /** * Initializes me with the core command stack to which I `delegate` my implementation * and my unique `id` that I use as my editing context in the core command stack. * * @param delegate the core command stack to which I delegate my API with my editing context * @param id my identifier, which I use as my editing context */ constructor( private readonly delegate: CoreCommandStack<K>, options: CommandStackOptions & { id: string } ) { const { id, ...otherOptions } = defaultCommandStackOptions(options); this.editingContext = id; this.options = otherOptions; } execute(command: Command<K>): CommandResult<K> { const result = this.delegate.execute(command, this.editingContext); if (!this.options.keepHistory) { // Discard the history on completion or failure return result.finally(() => this.flush()); } return result; } executeAndAppend(command: Command<K>): CommandResult<K> { // If we don't keep history, this will fail because there's no undo command // to append to, which is all correct. return this.delegate.executeAndAppend(this.editingContext, command); } undo(): CommandResult<K> { return this.delegate.undo(this.editingContext); } redo(): CommandResult<K> { return this.delegate.redo(this.editingContext); } canExecute(command: Command<K>): Promise<boolean> { return this.delegate.canExecute(command, this.editingContext); } canUndo(): Promise<boolean> { return this.delegate.canUndo(this.editingContext); } canRedo(): Promise<boolean> { return this.delegate.canRedo(this.editingContext); } getUndoCommand(): Command<K> | undefined { return this.delegate.getUndoCommand(this.editingContext); } getRedoCommand(): Command<K> | undefined { return this.delegate.getRedoCommand(this.editingContext); } flush(): Command<K>[] { return this.delegate.flush(this.editingContext); } markSaved(): void { this.delegate.markSaved(this.editingContext); } isDirty(): boolean { return this.delegate.isDirty(this.editingContext); } getDirtyModelIds(): K[] { return this.delegate.getDirtyModelIds(this.editingContext); } subscribe(): CommandStackSubscription<K> { const sub = this.delegate.subscribe(this.editingContext); const result: CommandStackSubscription<K> = { close: sub.close.bind(sub), }; sub.onContextChanged = (_, eventType, command) => result.onCommandStackChanged?.(eventType, command); sub.onDirtyStateChanged = (_, dirtyStateChanges) => result.onDirtyStateChanged?.(dirtyStateChanges); return result; } getCoreCommandStack(): CoreCommandStack<K> { return this.delegate; } }