@eclipse-glsp/client
Version:
A sprotty-based client for GLSP
320 lines (285 loc) • 14.1 kB
text/typescript
/********************************************************************************
* Copyright (c) 2019-2024 EclipseSource 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 {
Action,
AnchorComputerRegistry,
ChangeRoutingPointsOperation,
Connectable,
EdgeRouterRegistry,
GModelElement,
GModelRoot,
GRoutableElement,
GRoutingHandle,
ReconnectEdgeOperation,
TYPES,
canEditRouting,
findParentByFeature,
isConnectable,
isSelected
} from '@eclipse-glsp/sprotty';
import { inject, injectable, optional } from 'inversify';
import { DragAwareMouseListener } from '../../../base/drag-aware-mouse-listener';
import { CursorCSS, cursorFeedbackAction } from '../../../base/feedback/css-feedback';
import { FeedbackEmitter } from '../../../base/feedback/feedback-emitter';
import { ISelectionListener, SelectionService } from '../../../base/selection-service';
import { calcElementAndRoutingPoints, isRoutable, isRoutingHandle } from '../../../utils/gmodel-util';
import { GReconnectHandle, isReconnectHandle, isReconnectable, isSourceRoutingHandle, isTargetRoutingHandle } from '../../reconnect/model';
import { BaseEditTool } from '../base-tools';
import { IChangeBoundsManager } from '../change-bounds/change-bounds-manager';
import { DrawFeedbackEdgeAction, RemoveFeedbackEdgeAction, feedbackEdgeId } from '../edge-creation/dangling-edge-feedback';
import {
DrawFeedbackEdgeSourceAction,
FeedbackEdgeRouteMovingMouseListener,
FeedbackEdgeSourceMovingMouseListener,
FeedbackEdgeTargetMovingMouseListener,
HideEdgeReconnectHandlesFeedbackAction,
ShowEdgeReconnectHandlesFeedbackAction,
SwitchRoutingModeAction
} from './edge-edit-tool-feedback';
export class EdgeEditTool extends BaseEditTool {
static ID = 'glsp.edge-edit-tool';
protected selectionService: SelectionService;
protected anchorRegistry: AnchorComputerRegistry;
readonly edgeRouterRegistry?: EdgeRouterRegistry;
readonly changeBoundsManager: IChangeBoundsManager;
protected feedbackEdgeSourceMovingListener: FeedbackEdgeSourceMovingMouseListener;
protected feedbackEdgeTargetMovingListener: FeedbackEdgeTargetMovingMouseListener;
protected feedbackMovingListener: FeedbackEdgeRouteMovingMouseListener;
protected edgeEditListener: EdgeEditListener;
get id(): string {
return EdgeEditTool.ID;
}
enable(): void {
this.edgeEditListener = new EdgeEditListener(this);
// install feedback move mouse listener for client-side move updates
this.feedbackEdgeSourceMovingListener = new FeedbackEdgeSourceMovingMouseListener(this.anchorRegistry, this.feedbackDispatcher);
this.feedbackEdgeTargetMovingListener = new FeedbackEdgeTargetMovingMouseListener(this.anchorRegistry, this.feedbackDispatcher);
this.feedbackMovingListener = new FeedbackEdgeRouteMovingMouseListener(this.changeBoundsManager, this.edgeRouterRegistry);
this.toDisposeOnDisable.push(
this.edgeEditListener,
this.mouseTool.registerListener(this.edgeEditListener),
this.feedbackEdgeSourceMovingListener,
this.feedbackEdgeTargetMovingListener,
this.feedbackMovingListener,
this.selectionService.addListener(this.edgeEditListener)
);
}
registerFeedbackListeners(): void {
this.mouseTool.register(this.feedbackMovingListener);
this.mouseTool.register(this.feedbackEdgeSourceMovingListener);
this.mouseTool.register(this.feedbackEdgeTargetMovingListener);
}
deregisterFeedbackListeners(): void {
this.feedbackEdgeSourceMovingListener.dispose();
this.feedbackEdgeTargetMovingListener.dispose();
this.mouseTool.deregister(this.feedbackEdgeSourceMovingListener);
this.mouseTool.deregister(this.feedbackEdgeTargetMovingListener);
this.mouseTool.deregister(this.feedbackMovingListener);
}
}
export class EdgeEditListener extends DragAwareMouseListener implements ISelectionListener {
// active selection data
protected edge?: GRoutableElement;
protected routingHandle?: GRoutingHandle;
// new connectable (source or target) for edge
protected newConnectable?: GModelElement & Connectable;
// active reconnect handle data
protected reconnectMode?: 'NEW_SOURCE' | 'NEW_TARGET';
protected cursorFeedback: FeedbackEmitter;
protected editFeedback: FeedbackEmitter;
constructor(protected tool: EdgeEditTool) {
super();
this.cursorFeedback = this.tool.createFeedbackEmitter();
this.editFeedback = this.tool.createFeedbackEmitter();
}
protected isValidEdge(edge?: GRoutableElement): edge is GRoutableElement {
return edge !== undefined && edge.id !== feedbackEdgeId(edge.root) && isSelected(edge);
}
protected setEdgeSelected(edge: GRoutableElement): void {
this.edge = edge;
// note: order is important here as we want the reconnect handles to cover the routing handles
if (canEditRouting(edge)) {
this.editFeedback.add(
SwitchRoutingModeAction.create({ elementsToActivate: [this.edge.id] }),
SwitchRoutingModeAction.create({ elementsToDeactivate: [this.edge.id] })
);
}
if (isReconnectable(edge)) {
this.editFeedback.add(
ShowEdgeReconnectHandlesFeedbackAction.create(this.edge.id),
HideEdgeReconnectHandlesFeedbackAction.create()
);
}
this.editFeedback.submit();
}
protected isEdgeSelected(): boolean {
return this.edge !== undefined && isSelected(this.edge);
}
protected setReconnectHandleSelected(edge: GRoutableElement, reconnectHandle: GReconnectHandle): void {
if (this.edge && this.edge.target && this.edge.source) {
this.editFeedback.dispose();
if (isSourceRoutingHandle(edge, reconnectHandle)) {
this.editFeedback
.add(cursorFeedbackAction(CursorCSS.EDGE_RECONNECT), cursorFeedbackAction())
.add(
DrawFeedbackEdgeSourceAction.create({ elementTypeId: this.edge.type, targetId: this.edge.targetId }),
RemoveFeedbackEdgeAction.create()
)
.submit();
this.reconnectMode = 'NEW_SOURCE';
} else if (isTargetRoutingHandle(edge, reconnectHandle)) {
this.editFeedback
.add(cursorFeedbackAction(CursorCSS.EDGE_CREATION_TARGET), cursorFeedbackAction())
.add(
DrawFeedbackEdgeAction.create({ elementTypeId: this.edge.type, sourceId: this.edge.sourceId }),
RemoveFeedbackEdgeAction.create()
)
.submit();
this.reconnectMode = 'NEW_TARGET';
}
}
}
protected isReconnecting(): boolean {
return this.reconnectMode !== undefined;
}
protected isReconnectingNewSource(): boolean {
return this.reconnectMode === 'NEW_SOURCE';
}
protected setRoutingHandleSelected(edge: GRoutableElement, routingHandle: GRoutingHandle): void {
if (this.edge && this.edge.target && this.edge.source) {
this.routingHandle = routingHandle;
}
}
protected requiresReconnect(sourceId: string, targetId: string): boolean {
return this.edge !== undefined && (this.edge.sourceId !== sourceId || this.edge.targetId !== targetId);
}
protected setNewConnectable(connectable?: GModelElement & Connectable): void {
this.newConnectable = connectable;
}
protected isReadyToReconnect(): boolean | undefined {
return this.edge && this.isReconnecting() && this.newConnectable !== undefined;
}
protected isReadyToReroute(): boolean {
return this.routingHandle !== undefined;
}
override mouseDown(target: GModelElement, event: MouseEvent): Action[] {
const result: Action[] = super.mouseDown(target, event);
if (event.button === 0) {
const reconnectHandle = findParentByFeature(target, isReconnectHandle);
const routingHandle = !reconnectHandle ? findParentByFeature(target, isRoutingHandle) : undefined;
const edge = findParentByFeature(target, isRoutable);
if (this.isEdgeSelected() && edge && reconnectHandle) {
// PHASE 2 Reconnect: Select reconnect handle on selected edge
this.setReconnectHandleSelected(edge, reconnectHandle);
} else if (this.isEdgeSelected() && edge && routingHandle) {
// PHASE 2 Reroute: Select routing handle on selected edge
this.setRoutingHandleSelected(edge, routingHandle);
} else if (this.isValidEdge(edge)) {
// PHASE 1: Select edge
this.dispose();
this.tool.registerFeedbackListeners();
this.setEdgeSelected(edge);
}
} else if (event.button === 2) {
this.dispose();
}
return result;
}
protected override draggingMouseMove(target: GModelElement, event: MouseEvent): Action[] {
// reset any selected connectables when we are dragging, maybe the user is just panning
this.setNewConnectable(undefined);
return super.draggingMouseMove(target, event);
}
override mouseUp(target: GModelElement, event: MouseEvent): Action[] {
const result = super.mouseUp(target, event);
if (!this.isReadyToReconnect() && !this.isReadyToReroute()) {
return result;
}
if (this.edge && this.newConnectable) {
const sourceElementId = this.isReconnectingNewSource() ? this.newConnectable.id : this.edge.sourceId;
const targetElementId = this.isReconnectingNewSource() ? this.edge.targetId : this.newConnectable.id;
if (this.requiresReconnect(sourceElementId, targetElementId)) {
result.push(ReconnectEdgeOperation.create({ edgeElementId: this.edge.id, sourceElementId, targetElementId }));
}
this.dispose();
} else if (this.edge && this.routingHandle) {
// we need to re-retrieve the edge as it might have changed due to a server update since we do not reset the state between
// reroute actions
const latestEdge = target.index.getById(this.edge.id);
if (latestEdge && isRoutable(latestEdge)) {
const newRoutingPoints = calcElementAndRoutingPoints(latestEdge, this.tool.edgeRouterRegistry);
result.push(ChangeRoutingPointsOperation.create([newRoutingPoints]));
this.routingHandle = undefined;
}
}
return result;
}
override mouseOver(target: GModelElement, _event: MouseEvent): Action[] {
if (this.edge && this.isReconnecting()) {
const currentTarget = findParentByFeature(target, isConnectable);
if (!this.newConnectable || currentTarget !== this.newConnectable) {
this.setNewConnectable(currentTarget);
if (currentTarget) {
if (
(this.reconnectMode === 'NEW_SOURCE' && currentTarget.canConnect(this.edge, 'source')) ||
(this.reconnectMode === 'NEW_TARGET' && currentTarget.canConnect(this.edge, 'target'))
) {
this.cursorFeedback.add(cursorFeedbackAction(CursorCSS.EDGE_RECONNECT), cursorFeedbackAction()).submit();
return [];
}
}
this.cursorFeedback.add(cursorFeedbackAction(CursorCSS.OPERATION_NOT_ALLOWED), cursorFeedbackAction()).submit();
}
}
return [];
}
selectionChanged(root: Readonly<GModelRoot>, selectedElements: string[]): void {
if (this.edge) {
if (selectedElements.indexOf(this.edge.id) > -1) {
// our active edge is still selected, nothing to do
return;
}
if (this.isReconnecting()) {
// we are reconnecting, so we may have clicked on a potential target
return;
}
// try to find some other selected element and mark that active
for (const elementId of selectedElements.reverse()) {
const element = root.index.getById(elementId);
if (element) {
const edge = findParentByFeature(element, isRoutable);
if (this.isValidEdge(edge)) {
// PHASE 1: Select edge
this.setEdgeSelected(edge);
return;
}
}
}
this.dispose();
}
}
override dispose(): void {
this.edge = undefined;
this.reconnectMode = undefined;
this.newConnectable = undefined;
this.routingHandle = undefined;
this.cursorFeedback.dispose();
this.editFeedback.dispose();
this.tool.deregisterFeedbackListeners();
super.dispose();
}
}