Skip to content

Commit

Permalink
Make command executions (e.g. animations) stoppable (#414)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
jbicker authored Jan 19, 2024
1 parent 3f30f8d commit d391f0d
Show file tree
Hide file tree
Showing 6 changed files with 66 additions and 12 deletions.
12 changes: 7 additions & 5 deletions examples/circlegraph/src/standalone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 => {
Expand All @@ -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 => {
Expand All @@ -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();
});

Expand Down
6 changes: 4 additions & 2 deletions packages/sprotty-protocol/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}
}
Expand Down
15 changes: 15 additions & 0 deletions packages/sprotty/src/base/animations/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ export abstract class Animation {
constructor(protected context: CommandExecutionContext, protected ease: (x: number) => number = easeInOut) {
}

protected stopped = false;

start(): Promise<SModelRootImpl> {
// in case start() is called multiple times, we need to reset the stopped flag
this.stopped = false;
return new Promise<SModelRootImpl>(
(resolve: (model: SModelRootImpl) => void, reject: (model: SModelRootImpl) => void) => {
let start: number | undefined = undefined;
Expand All @@ -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);
}
Expand All @@ -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.
Expand Down
17 changes: 16 additions & 1 deletion packages/sprotty/src/base/commands/command-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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<string, IStoppableCommand> = new Map();

/**
* System commands should be transparent to the user in undo/redo
* operations. When a system command is executed when the redo
Expand Down Expand Up @@ -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<CommandStackState>(resolve => {
let target: 'main' | 'hidden' | 'popup';
Expand Down
11 changes: 11 additions & 0 deletions packages/sprotty/src/base/commands/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions packages/sprotty/src/features/move/move.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, ResolvedElementMove> = 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<SRoutableElementImpl, ResolvedHandleMove[]>();
Expand Down Expand Up @@ -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;
}
Expand Down

0 comments on commit d391f0d

Please sign in to comment.