sprotty
Version:
A next-gen framework for graphical views
689 lines (631 loc) • 29.1 kB
text/typescript
/********************************************************************************
* Copyright (c) 2017-2020 TypeFox and others.
*
* 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: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
import { inject, injectable, optional } from 'inversify';
import { VNode } from 'snabbdom';
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, IStoppableCommand } from '../../base/commands/command';
import { SChildElementImpl, SModelElementImpl, SModelRootImpl, isParent } from '../../base/model/smodel';
import { findParentByFeature, translatePoint } from '../../base/model/smodel-utils';
import { TYPES } from '../../base/types';
import { MouseListener } from '../../base/views/mouse-tool';
import { IVNodePostprocessor } from '../../base/views/vnode-postprocessor';
import { setAttr } from '../../base/views/vnode-utils';
import { SEdgeImpl } from '../../graph/sgraph';
import { CommitModelAction } from '../../model-source/commit-model';
import { findChildrenAtPosition, isAlignable } from '../bounds/model';
import { CreatingOnDrag, isCreatingOnDrag } from '../edit/create-on-drag';
import { SwitchEditModeAction } from '../edit/edit-routing';
import { ReconnectCommand } from '../edit/reconnect';
import { edgeInProgressID, edgeInProgressTargetHandleID, isConnectable, SConnectableElementImpl, SRoutableElementImpl, SRoutingHandleImpl } from '../routing/model';
import { EdgeMemento, EdgeRouterRegistry, EdgeSnapshot, RoutedPoint } from '../routing/routing';
import { isEdgeLayoutable } from '../edge-layout/model';
import { isSelectable } from '../select/model';
import { isViewport } from '../viewport/model';
import { isLocateable, isMoveable } from './model';
import { ISnapper } from './snap';
export interface ElementMove {
elementId: string
elementType?: string
fromPosition?: Point
toPosition: Point
}
export interface ResolvedElementMove {
element: SModelElementImpl & Locateable
fromPosition: Point
toPosition: Point
}
export interface ResolvedHandleMove {
handle: SRoutingHandleImpl
fromPosition: Point
toPosition: Point
}
export class MoveCommand extends MergeableCommand implements IStoppableCommand {
static readonly KIND = MoveAction.KIND;
edgeRouterRegistry?: EdgeRouterRegistry;
protected resolvedMoves: Map<string, ResolvedElementMove> = new Map;
protected edgeMementi: EdgeMemento[] = [];
protected animation: Animation | undefined;
stoppableCommandKey: string;
constructor( protected readonly action: MoveAction) {
super();
this.stoppableCommandKey = MoveCommand.KIND;
}
// 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[]>();
const attachedEdgeShifts = new Map<SRoutableElementImpl, Point>();
this.action.moves.forEach(move => {
const element = index.getById(move.elementId);
if (element instanceof SRoutingHandleImpl && this.edgeRouterRegistry) {
const edge = element.parent;
if (edge instanceof SRoutableElementImpl) {
const resolvedMove = this.resolveHandleMove(element, edge, move);
if (resolvedMove) {
let movesByEdge = edge2handleMoves.get(edge);
if (!movesByEdge) {
movesByEdge = [];
edge2handleMoves.set(edge, movesByEdge);
}
movesByEdge.push(resolvedMove);
}
}
} else if (element && isLocateable(element)) {
const resolvedMove = this.resolveElementMove(element, move);
if (resolvedMove) {
this.resolvedMoves.set(resolvedMove.element.id, resolvedMove);
if (this.edgeRouterRegistry) {
const handleEdges = (el: SModelElementImpl) => {
index.getAttachedElements(el).forEach(edge => {
if (edge instanceof SRoutableElementImpl && !this.isChildOfMovedElements(edge as SRoutableElementImpl)) {
const existingDelta = attachedEdgeShifts.get(edge);
const newDelta = Point.subtract(resolvedMove.toPosition, resolvedMove.fromPosition);
const delta = (existingDelta)
? Point.linear(existingDelta, newDelta, 0.5)
: newDelta;
attachedEdgeShifts.set(edge, delta);
}
});
};
const handleEdgesForChildren = (el: SModelElementImpl) => {
if (isParent(el)) {
el.children.forEach(childEl => {
if (childEl instanceof SModelElementImpl) {
if (childEl instanceof SConnectableElementImpl) {
handleEdges(childEl);
}
handleEdgesForChildren(childEl);
}
});
}
};
handleEdgesForChildren(element);
handleEdges(element);
}
}
}
});
this.doMove(edge2handleMoves, attachedEdgeShifts);
if (this.action.animate) {
this.undoMove();
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();
}
return context.root;
}
protected resolveHandleMove(handle: SRoutingHandleImpl, edge: SRoutableElementImpl, move: ElementMove): ResolvedHandleMove | undefined {
let fromPosition = move.fromPosition;
if (!fromPosition) {
const router = this.edgeRouterRegistry!.get(edge.routerKind);
fromPosition = router.getHandlePosition(edge, router.route(edge), handle);
}
if (fromPosition)
return {
handle,
fromPosition,
toPosition: move.toPosition
};
return undefined;
}
protected resolveElementMove(element: SModelElementImpl & Locateable, move: ElementMove): ResolvedElementMove | undefined {
const fromPosition = move.fromPosition
|| { x: element.position.x, y: element.position.y };
return {
element,
fromPosition,
toPosition: move.toPosition
};
}
protected doMove(edge2move: Map<SRoutableElementImpl, ResolvedHandleMove[]>, attachedEdgeShifts: Map<SRoutableElementImpl, Point>) {
this.resolvedMoves.forEach(res => {
res.element.position = res.toPosition;
});
// reset edges to state before
edge2move.forEach((moves, edge) => {
const router = this.edgeRouterRegistry!.get(edge.routerKind);
const before = router.takeSnapshot(edge);
router.applyHandleMoves(edge, moves);
const after = router.takeSnapshot(edge);
this.edgeMementi.push({ edge, before, after });
});
attachedEdgeShifts.forEach((delta, edge) => {
if (!edge2move.get(edge)) {
const router = this.edgeRouterRegistry!.get(edge.routerKind);
const before = router.takeSnapshot(edge);
if (this.isAttachedEdge(edge)) {
// move the entire edge when both source and target are moved
edge.routingPoints = edge.routingPoints.map(rp => Point.add(rp, delta));
} else {
// add/remove RPs according to the new source/target positions
const updateHandles = isSelectable(edge) && edge.selected;
router.cleanupRoutingPoints(edge, edge.routingPoints, updateHandles, this.action.finished);
}
const after = router.takeSnapshot(edge);
this.edgeMementi.push({ edge, before, after });
}
});
}
protected isChildOfMovedElements(el: SChildElementImpl): boolean {
const parent = el.parent;
if (Array.from(this.resolvedMoves.values()).map(rm => rm.element.id).includes(parent.id)) {
return true;
}
if (parent instanceof SChildElementImpl) {
return this.isChildOfMovedElements(parent);
}
return false;
};
// tests if the edge is attached to the moved element directly or to on of their children
protected isAttachedEdge(edge: SRoutableElementImpl): boolean {
const source = edge.source;
const target = edge.target;
const checkMovedElementsAndChildren = (sourceOrTarget: SConnectableElementImpl): boolean => {
return Boolean(this.resolvedMoves.get(sourceOrTarget.id)) || this.isChildOfMovedElements(sourceOrTarget);
};
return Boolean(
source &&
target &&
checkMovedElementsAndChildren(source) &&
checkMovedElementsAndChildren(target)
);
}
protected undoMove() {
this.resolvedMoves.forEach(res => {
(res.element as any).position = res.fromPosition;
});
this.edgeMementi.forEach(memento => {
const router = this.edgeRouterRegistry!.get(memento.edge.routerKind);
router.applySnapshot(memento.edge, memento.before);
});
}
undo(context: CommandExecutionContext): Promise<SModelRootImpl> {
return new CompoundAnimation(context.root, context, [
new MoveAnimation(context.root, this.resolvedMoves, context, true),
new MorphEdgesAnimation(context.root, this.edgeMementi, context, true)
]).start();
}
redo(context: CommandExecutionContext): Promise<SModelRootImpl> {
return new CompoundAnimation(context.root, context, [
new MoveAnimation(context.root, this.resolvedMoves, context, false),
new MorphEdgesAnimation(context.root, this.edgeMementi, context, false)
]).start();
}
override merge(other: ICommand, context: CommandExecutionContext) {
if (!this.action.animate && other instanceof MoveCommand) {
other.resolvedMoves.forEach(
(otherMove, otherElementId) => {
const existingMove = this.resolvedMoves.get(otherElementId);
if (existingMove) {
existingMove.toPosition = otherMove.toPosition;
} else {
this.resolvedMoves.set(otherElementId, otherMove);
}
}
);
other.edgeMementi.forEach(otherMemento => {
const existingMemento = this.edgeMementi.find(edgeMemento => edgeMemento.edge.id === otherMemento.edge.id);
if (existingMemento) {
existingMemento.after = otherMemento.after;
} else {
this.edgeMementi.push(otherMemento);
}
});
return true;
} else if (other instanceof ReconnectCommand) {
const otherMemento = other.memento;
if (otherMemento) {
const existingMemento = this.edgeMementi.find(edgeMemento => edgeMemento.edge.id === otherMemento.edge.id);
if (existingMemento) {
existingMemento.after = otherMemento.after;
} else {
this.edgeMementi.push(otherMemento);
}
}
return true;
}
return false;
}
}
export class MoveAnimation extends Animation {
constructor(protected model: SModelRootImpl,
public elementMoves: Map<string, ResolvedElementMove>,
context: CommandExecutionContext,
protected reverse: boolean = false) {
super(context);
}
tween(t: number) {
this.elementMoves.forEach((elementMove) => {
if (this.reverse) {
elementMove.element.position = {
x: (1 - t) * elementMove.toPosition.x + t * elementMove.fromPosition.x,
y: (1 - t) * elementMove.toPosition.y + t * elementMove.fromPosition.y
};
} else {
elementMove.element.position = {
x: (1 - t) * elementMove.fromPosition.x + t * elementMove.toPosition.x,
y: (1 - t) * elementMove.fromPosition.y + t * elementMove.toPosition.y
};
}
});
return this.model;
}
}
interface ExpandedEdgeMorph {
startExpandedRoute: Point[],
endExpandedRoute: Point[],
memento: EdgeMemento
}
export class MorphEdgesAnimation extends Animation {
protected expanded: ExpandedEdgeMorph[] = [];
constructor(protected model: SModelRootImpl,
originalMementi: EdgeMemento[],
context: CommandExecutionContext,
protected reverse: boolean = false) {
super(context);
originalMementi.forEach(edgeMemento => {
const start = this.reverse ? edgeMemento.after : edgeMemento.before;
const end = this.reverse ? edgeMemento.before : edgeMemento.after;
const startRoute = start.routedPoints;
const endRoute = end.routedPoints;
const maxRoutingPoints = Math.max(startRoute.length, endRoute.length);
this.expanded.push({
startExpandedRoute: this.growToSize(startRoute, maxRoutingPoints),
endExpandedRoute: this.growToSize(endRoute, maxRoutingPoints),
memento: edgeMemento
});
});
}
protected midPoint(edgeMemento: EdgeMemento): Point {
const edge = edgeMemento.edge;
const source = edgeMemento.edge.source!;
const target = edgeMemento.edge.target!;
return Point.linear(
translatePoint(Bounds.center(source.bounds), source.parent, edge.parent),
translatePoint(Bounds.center(target.bounds), target.parent, edge.parent),
0.5);
}
override start() {
this.expanded.forEach(morph => {
morph.memento.edge.removeAll(e => e instanceof SRoutingHandleImpl);
});
return super.start();
}
tween(t: number) {
if (t === 1) {
this.expanded.forEach(morph => {
const memento = morph.memento;
if (this.reverse)
memento.before.router.applySnapshot(memento.edge, memento.before);
else
memento.after.router.applySnapshot(memento.edge, memento.after);
});
} else {
this.expanded.forEach(morph => {
const newRoutingPoints: Point[] = [];
// ignore source and target anchor
for (let i = 1; i < morph.startExpandedRoute.length - 1; ++i)
newRoutingPoints.push(Point.linear(morph.startExpandedRoute[i], morph.endExpandedRoute[i], t));
const closestSnapshot = t < 0.5 ? morph.memento.before : morph.memento.after;
const newSnapshot: EdgeSnapshot = {
...closestSnapshot,
routingPoints: newRoutingPoints,
routingHandles: []
};
closestSnapshot.router.applySnapshot(morph.memento.edge, newSnapshot);
});
}
return this.model;
}
protected growToSize(route: RoutedPoint[], targetSize: number): Point[] {
const diff = targetSize - route.length;
if (diff <= 0)
return route;
const result: Point[] = [];
result.push(route[0]);
const deltaDiff = 1 / (diff + 1);
const deltaSmaller = 1 / (route.length - 1);
let nextInsertion = 1;
for (let i = 1; i < route.length; ++i) {
const pos = deltaSmaller * i;
let insertions = 0;
while (pos > (nextInsertion + insertions) * deltaDiff)
++insertions;
nextInsertion += insertions;
for (let j = 0; j < insertions; ++j) {
const p = Point.linear(route[i - 1], route[i], (j + 1) / (insertions + 1));
result.push(p);
}
result.push(route[i]);
}
return result;
}
}
export class MoveMouseListener extends MouseListener {
edgeRouterRegistry?: EdgeRouterRegistry;
snapper?: ISnapper;
hasDragged = false;
startDragPosition: Point | undefined;
elementId2startPos = new Map<string, Point>();
override mouseDown(target: SModelElementImpl, event: MouseEvent): (Action | Promise<Action>)[] {
if (event.button === 0) {
const moveable = findParentByFeature(target, isMoveable);
const isRoutingHandle = target instanceof SRoutingHandleImpl;
if (moveable !== undefined || isRoutingHandle || isCreatingOnDrag(target)) {
this.startDragPosition = { x: event.pageX, y: event.pageY };
} else {
this.startDragPosition = undefined;
}
this.hasDragged = false;
if (isCreatingOnDrag(target)) {
return this.startCreatingOnDrag(target, event);
} else if (isRoutingHandle) {
return this.activateRoutingHandle(target, event);
}
}
return [];
}
protected startCreatingOnDrag(target: CreatingOnDrag, event: MouseEvent): (Action | Promise<Action>)[] {
const result: Action[] = [];
result.push(SelectAllAction.create({ select: false }));
result.push(target.createAction(edgeInProgressID));
result.push(SelectAction.create({ selectedElementsIDs: [edgeInProgressID] }));
result.push(SwitchEditModeAction.create({ elementsToActivate: [edgeInProgressID] }));
result.push(SelectAction.create({ selectedElementsIDs: [edgeInProgressTargetHandleID] }));
result.push(SwitchEditModeAction.create({ elementsToActivate: [edgeInProgressTargetHandleID] }));
return result;
}
protected activateRoutingHandle(target: SRoutingHandleImpl, event: MouseEvent): (Action | Promise<Action>)[] {
return [SwitchEditModeAction.create({ elementsToActivate: [target.id] })];
}
override mouseMove(target: SModelElementImpl, event: MouseEvent): Action[] {
const result: Action[] = [];
if (event.buttons === 0)
this.mouseUp(target, event);
else if (this.startDragPosition) {
if (this.elementId2startPos.size === 0) {
this.collectStartPositions(target.root);
}
this.hasDragged = true;
const moveAction = this.getElementMoves(target, event, false);
if (moveAction)
result.push(moveAction);
}
return result;
}
protected collectStartPositions(root: SModelRootImpl) {
const selectedElements = new Set<SModelElementImpl>(root.index.all()
.filter(element => isSelectable(element) && element.selected));
selectedElements.forEach(element => {
if (!this.isChildOfSelected(selectedElements, element)) {
if (isMoveable(element))
this.elementId2startPos.set(element.id, element.position);
else if (element instanceof SRoutingHandleImpl) {
const position = this.getHandlePosition(element);
if (position)
this.elementId2startPos.set(element.id, position);
}
}
});
}
protected isChildOfSelected(selectedElements: Set<SModelElementImpl>, element: SModelElementImpl): boolean {
while (element instanceof SChildElementImpl) {
element = element.parent;
if (isMoveable(element) && selectedElements.has(element)) {
return true;
}
}
return false;
}
protected getElementMoves(target: SModelElementImpl, event: MouseEvent, isFinished: boolean): MoveAction | undefined {
if (!this.startDragPosition)
return undefined;
const elementMoves: ElementMove[] = [];
const viewport = findParentByFeature(target, isViewport);
const zoom = viewport ? viewport.zoom : 1;
const delta = {
x: (event.pageX - this.startDragPosition.x) / zoom,
y: (event.pageY - this.startDragPosition.y) / zoom
};
this.elementId2startPos.forEach((startPosition, elementId) => {
const element = target.root.index.getById(elementId);
if (element) {
const move = this.createElementMove(element, startPosition, delta, event);
if (move) {
elementMoves.push(move);
}
}
});
if (elementMoves.length > 0)
return MoveAction.create(elementMoves, { animate: false, finished: isFinished });
else
return undefined;
}
protected createElementMove(element: SModelElementImpl, startPosition: Point, delta: Point, event: MouseEvent): ElementMove | undefined {
const toPosition = this.snap({
x: startPosition.x + delta.x,
y: startPosition.y + delta.y
}, element, !event.shiftKey);
if (isMoveable(element)) {
return {
elementId: element.id,
elementType: element.type,
fromPosition: {
x: element.position.x,
y: element.position.y
},
toPosition
};
} else if (element instanceof SRoutingHandleImpl) {
const point = this.getHandlePosition(element);
if (point !== undefined) {
return {
elementId: element.id,
elementType: element.type,
fromPosition: point,
toPosition
};
}
}
return undefined;
}
protected snap(position: Point, element: SModelElementImpl, isSnap: boolean): Point {
if (isSnap && this.snapper)
return this.snapper.snap(position, element);
else
return position;
}
protected getHandlePosition(handle: SRoutingHandleImpl): Point | undefined {
if (this.edgeRouterRegistry) {
const parent = handle.parent;
if (!(parent instanceof SRoutableElementImpl))
return undefined;
const router = this.edgeRouterRegistry.get(parent.routerKind);
const route = router.route(parent);
return router.getHandlePosition(parent, route, handle);
}
return undefined;
}
override mouseEnter(target: SModelElementImpl, event: MouseEvent): Action[] {
if (target instanceof SModelRootImpl && event.buttons === 0 && !this.startDragPosition)
this.mouseUp(target, event);
return [];
}
override mouseUp(target: SModelElementImpl, event: MouseEvent): (Action | Promise<Action>)[] {
const result: Action[] = [];
if (this.startDragPosition) {
const moveAction = this.getElementMoves(target, event, true);
if (moveAction) {
result.push(moveAction);
}
target.root.index.all().forEach(element => {
if (element instanceof SRoutingHandleImpl) {
result.push(...this.deactivateRoutingHandle(element, target, event));
}
});
}
if (!result.some(a => a.kind === ReconnectAction.KIND)) {
const edgeInProgress = target.root.index.getById(edgeInProgressID);
if (edgeInProgress instanceof SChildElementImpl) {
result.push(this.deleteEdgeInProgress(edgeInProgress));
}
}
if (this.hasDragged) {
result.push(CommitModelAction.create());
}
this.hasDragged = false;
this.startDragPosition = undefined;
this.elementId2startPos.clear();
return result;
}
protected deactivateRoutingHandle(element: SRoutingHandleImpl, target: SModelElementImpl, event: MouseEvent): Action[] {
const result: Action[] = [];
const parent = element.parent;
if (parent instanceof SRoutableElementImpl && element.danglingAnchor) {
const handlePos = this.getHandlePosition(element);
if (handlePos) {
const handlePosAbs = translatePoint(handlePos, element.parent, element.root);
const newEnd = findChildrenAtPosition(target.root, handlePosAbs)
.find(e => isConnectable(e) && e.canConnect(parent, element.kind as ('source' | 'target')));
if (newEnd && this.hasDragged) {
result.push(ReconnectAction.create({
routableId: element.parent.id,
newSourceId: element.kind === 'source' ? newEnd.id : parent.sourceId,
newTargetId: element.kind === 'target' ? newEnd.id : parent.targetId
}));
}
}
}
if (element.editMode) {
result.push(SwitchEditModeAction.create({ elementsToDeactivate: [element.id] }));
}
return result;
}
protected deleteEdgeInProgress(edgeInProgress: SChildElementImpl): Action {
const deleteIds: string[] = [];
deleteIds.push(edgeInProgressID);
edgeInProgress.children.forEach(c => {
if (c instanceof SRoutingHandleImpl && c.danglingAnchor)
deleteIds.push(c.danglingAnchor.id);
});
return DeleteElementAction.create(deleteIds);
}
override decorate(vnode: VNode, element: SModelElementImpl): VNode {
return vnode;
}
}
export class LocationPostprocessor implements IVNodePostprocessor {
decorate(vnode: VNode, element: SModelElementImpl): VNode {
if (isEdgeLayoutable(element) && element.parent instanceof SEdgeImpl) {
// The element is handled by EdgeLayoutDecorator
return vnode;
}
let translate: string = '';
if (isLocateable(element) && element instanceof SChildElementImpl && element.parent !== undefined) {
const pos = element.position;
if (pos.x !== 0 || pos.y !== 0) {
translate = 'translate(' + pos.x + ', ' + pos.y + ')';
}
}
if (isAlignable(element)) {
const ali = element.alignment;
if (ali.x !== 0 || ali.y !== 0) {
if (translate.length > 0) {
translate += ' ';
}
translate += 'translate(' + ali.x + ', ' + ali.y + ')';
}
}
if (translate.length > 0) {
setAttr(vnode, 'transform', translate);
}
return vnode;
}
postUpdate(): void {
}
}