From d391f0d9883231d74430a0fa65a159b8b85fbac0 Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Fri, 19 Jan 2024 09:44:03 +0100 Subject: [PATCH] Make command executions (e.g. animations) stoppable (#414) Commands that implement the new introduced IStoppableCommand do stop the last execution immediately to start another one of the same kind. Signed-off-by: Jan Bicker --- examples/circlegraph/src/standalone.ts | 12 +++++++----- packages/sprotty-protocol/src/actions.ts | 6 ++++-- .../sprotty/src/base/animations/animation.ts | 15 +++++++++++++++ .../sprotty/src/base/commands/command-stack.ts | 17 ++++++++++++++++- packages/sprotty/src/base/commands/command.ts | 11 +++++++++++ packages/sprotty/src/features/move/move.ts | 17 +++++++++++++---- 6 files changed, 66 insertions(+), 12 deletions(-) diff --git a/examples/circlegraph/src/standalone.ts b/examples/circlegraph/src/standalone.ts index bdbb55e3..970dee0a 100644 --- a/examples/circlegraph/src/standalone.ts +++ b/examples/circlegraph/src/standalone.ts @@ -77,11 +77,15 @@ export default async function runCircleGraph() { graph.children.push(...newElements); } + let viewport = initialViewport; + window.addEventListener('resize', async () => { + viewport = await modelSource.getViewport(); + }); + // Run modelSource.setModel(graph); async function createNode(point?: Point) { - const viewport = await modelSource.getViewport(); const newElements = addNode(getVisibleBounds(viewport)); if (point) { const adjust = (offset: number) => { @@ -103,7 +107,6 @@ export default async function runCircleGraph() { }); document.getElementById('scrambleAll')!.addEventListener('click', async () => { - const viewport = await modelSource.getViewport(); const bounds = getVisibleBounds(viewport); const nodeMoves: ElementMove[] = []; graph.children.forEach(shape => { @@ -117,13 +120,12 @@ export default async function runCircleGraph() { }); } }); - dispatcher.dispatch(MoveAction.create(nodeMoves, { animate: true })); + dispatcher.dispatch(MoveAction.create(nodeMoves, { animate: true, stoppable: true })); focusGraph(); }); document.getElementById('scrambleSelection')!.addEventListener('click', async () => { const selection = await modelSource.getSelection(); - const viewport = await modelSource.getViewport(); const bounds = getVisibleBounds(viewport); const nodeMoves: ElementMove[] = []; selection.forEach(shape => { @@ -137,7 +139,7 @@ export default async function runCircleGraph() { }); } }); - dispatcher.dispatch(MoveAction.create(nodeMoves, { animate: true })); + dispatcher.dispatch(MoveAction.create(nodeMoves, { animate: true, stoppable: true })); focusGraph(); }); diff --git a/packages/sprotty-protocol/src/actions.ts b/packages/sprotty-protocol/src/actions.ts index 910abb20..6a3a509e 100644 --- a/packages/sprotty-protocol/src/actions.ts +++ b/packages/sprotty-protocol/src/actions.ts @@ -676,16 +676,18 @@ export interface MoveAction extends Action { moves: ElementMove[] animate: boolean finished: boolean + stoppable: boolean } export namespace MoveAction { export const KIND = 'move'; - export function create(moves: ElementMove[], options: { animate?: boolean, finished?: boolean } = {}): MoveAction { + export function create(moves: ElementMove[], options: { animate?: boolean, finished?: boolean, stoppable?: boolean } = {}): MoveAction { return { kind: KIND, moves, animate: options.animate ?? true, - finished: options.finished ?? false + finished: options.finished ?? false, + stoppable: options.stoppable ?? false }; } } diff --git a/packages/sprotty/src/base/animations/animation.ts b/packages/sprotty/src/base/animations/animation.ts index 941e3cc4..663777d5 100644 --- a/packages/sprotty/src/base/animations/animation.ts +++ b/packages/sprotty/src/base/animations/animation.ts @@ -27,7 +27,11 @@ export abstract class Animation { constructor(protected context: CommandExecutionContext, protected ease: (x: number) => number = easeInOut) { } + protected stopped = false; + start(): Promise { + // in case start() is called multiple times, we need to reset the stopped flag + this.stopped = false; return new Promise( (resolve: (model: SModelRootImpl) => void, reject: (model: SModelRootImpl) => void) => { let start: number | undefined = undefined; @@ -47,6 +51,9 @@ export abstract class Animation { if (t === 1) { this.context.logger.log(this, (frames * 1000 / this.context.duration) + ' fps'); resolve(current); + } else if (this.stopped) { + this.context.logger.log(this, 'Animation stopped at ' + (t * 100) + '%'); + resolve(current); } else { this.context.syncer.onNextFrame(lambda); } @@ -60,6 +67,14 @@ export abstract class Animation { }); } + /** + * Stop the animation at the current state. + * The promise returned by start() will be resolved with the current state after the next tweening step. + */ + stop(): void { + this.stopped = true; + } + /** * This method called by the animation at each rendering pass until * the duration is reached. Implement it to interpolate the state. diff --git a/packages/sprotty/src/base/commands/command-stack.ts b/packages/sprotty/src/base/commands/command-stack.ts index 4ada1ce3..129d9beb 100644 --- a/packages/sprotty/src/base/commands/command-stack.ts +++ b/packages/sprotty/src/base/commands/command-stack.ts @@ -25,7 +25,7 @@ import { IViewer, IViewerProvider } from "../views/viewer"; import { CommandStackOptions } from './command-stack-options'; import { HiddenCommand, ICommand, CommandExecutionContext, CommandReturn, SystemCommand, - MergeableCommand, PopupCommand, ResetCommand, CommandResult + MergeableCommand, PopupCommand, ResetCommand, CommandResult, isStoppableCommand, IStoppableCommand } from './command'; /** @@ -118,6 +118,11 @@ export class CommandStack implements ICommandStack { protected undoStack: ICommand[] = []; protected redoStack: ICommand[] = []; + /** + * Map which holds the last stoppable command for certain action kinds. + */ + protected stoppableCommands: Map = new Map(); + /** * System commands should be transparent to the user in undo/redo * operations. When a system command is executed when the redo @@ -210,6 +215,16 @@ export class CommandStack implements ICommandStack { protected handleCommand(command: ICommand, operation: (context: CommandExecutionContext) => CommandReturn, beforeResolve: (command: ICommand, context: CommandExecutionContext) => void) { + // If the command implements the IStoppableCommand interface, we first need to stop the execution of the + // previous command with the same action kind and then store the new command as the last stoppable command. + if (isStoppableCommand(command)) { + const stoppableCommand = this.stoppableCommands.get((command as any).action.kind); + if (stoppableCommand) { + stoppableCommand.stopExecution(); + } + this.stoppableCommands.set((command as any).action.kind, command); + } + this.currentPromise = this.currentPromise.then(state => new Promise(resolve => { let target: 'main' | 'hidden' | 'popup'; diff --git a/packages/sprotty/src/base/commands/command.ts b/packages/sprotty/src/base/commands/command.ts index fb47452f..176d8a22 100644 --- a/packages/sprotty/src/base/commands/command.ts +++ b/packages/sprotty/src/base/commands/command.ts @@ -52,6 +52,17 @@ export interface ICommand { redo(context: CommandExecutionContext): CommandReturn } +/** + * A stoppable commands execution (e.g. one that starts an animation) can be interrupted. + */ +export interface IStoppableCommand extends ICommand { + stopExecution(): void +} + +export function isStoppableCommand(command: any): command is IStoppableCommand { + return command && 'stopExecution' in command && typeof command.stopExecution === 'function'; +} + /** * Commands return the changed model or a Promise for it. Promises * serve animating commands to render some intermediate states before diff --git a/packages/sprotty/src/features/move/move.ts b/packages/sprotty/src/features/move/move.ts index d579ab83..9dbe2e35 100644 --- a/packages/sprotty/src/features/move/move.ts +++ b/packages/sprotty/src/features/move/move.ts @@ -20,7 +20,7 @@ import { Locateable } from 'sprotty-protocol/lib/model'; import { Bounds, Point } from 'sprotty-protocol/lib/utils/geometry'; import { Action, DeleteElementAction, ReconnectAction, SelectAction, SelectAllAction, MoveAction } from 'sprotty-protocol/lib/actions'; import { Animation, CompoundAnimation } from '../../base/animations/animation'; -import { CommandExecutionContext, ICommand, MergeableCommand, CommandReturn } from '../../base/commands/command'; +import { CommandExecutionContext, ICommand, MergeableCommand, CommandReturn, IStoppableCommand } from '../../base/commands/command'; import { SChildElementImpl, SModelElementImpl, SModelRootImpl } from '../../base/model/smodel'; import { findParentByFeature, translatePoint } from '../../base/model/smodel-utils'; import { TYPES } from '../../base/types'; @@ -61,18 +61,27 @@ export interface ResolvedHandleMove { } @injectable() -export class MoveCommand extends MergeableCommand { +export class MoveCommand extends MergeableCommand implements IStoppableCommand { static readonly KIND = MoveAction.KIND; @inject(EdgeRouterRegistry) @optional() edgeRouterRegistry?: EdgeRouterRegistry; protected resolvedMoves: Map = new Map; protected edgeMementi: EdgeMemento[] = []; + protected animation: Animation | undefined; constructor(@inject(TYPES.Action) protected readonly action: MoveAction) { super(); } + // stop the execution of the CompoundAnimation started below + stopExecution(): void { + if (this.animation) { + this.animation.stop(); + this.animation = undefined; + } + } + execute(context: CommandExecutionContext): CommandReturn { const index = context.root.index; const edge2handleMoves = new Map(); @@ -114,10 +123,10 @@ export class MoveCommand extends MergeableCommand { this.doMove(edge2handleMoves, attachedEdgeShifts); if (this.action.animate) { this.undoMove(); - return new CompoundAnimation(context.root, context, [ + return (this.animation = new CompoundAnimation(context.root, context, [ new MoveAnimation(context.root, this.resolvedMoves, context, false), new MorphEdgesAnimation(context.root, this.edgeMementi, context, false) - ]).start(); + ])).start(); } return context.root; }