Skip to content

Commit

Permalink
Improve relationship creation tool (#53)
Browse files Browse the repository at this point in the history
* Improve relationship creation tool

- Adapt edge creation tool to desired behavior
- Only perform selection when select tool is active (default)
- Select edge after it has been created

* Moved example files into subfolders for readability.

---------

Co-authored-by: Harmen Wessels <97173058+harmen-xb@users.noreply.github.com>
  • Loading branch information
martin-fleck-at and harmen-xb committed Mar 19, 2024
1 parent 7514d76 commit d6e9ef8
Show file tree
Hide file tree
Showing 21 changed files with 337 additions and 9 deletions.
Expand Up @@ -3,7 +3,14 @@
********************************************************************************/

import { RELATIONSHIP_EDGE_TYPE } from '@crossbreeze/protocol';
import { Command, CreateEdgeOperation, JsonCreateEdgeOperationHandler, ModelState } from '@eclipse-glsp/server';
import {
ActionDispatcher,
Command,
CreateEdgeOperation,
JsonCreateEdgeOperationHandler,
ModelState,
SelectAction
} from '@eclipse-glsp/server';
import { inject, injectable } from 'inversify';
import { URI, Utils as UriUtils } from 'vscode-uri';
import { CrossModelRoot, EntityNode, Relationship, RelationshipEdge, isCrossModelRoot } from '../../../language-server/generated/ast.js';
Expand All @@ -17,6 +24,7 @@ export class SystemDiagramCreateEdgeOperationHandler extends JsonCreateEdgeOpera
elementTypeIds = [RELATIONSHIP_EDGE_TYPE];

@inject(ModelState) protected override modelState!: SystemModelState;
@inject(ActionDispatcher) protected actionDispatcher!: ActionDispatcher;

createCommand(operation: CreateEdgeOperation): Command {
return new CrossModelCommand(this.modelState, () => this.createEdge(operation));
Expand Down Expand Up @@ -48,6 +56,9 @@ export class SystemDiagramCreateEdgeOperationHandler extends JsonCreateEdgeOpera
}
};
this.modelState.systemDiagram.edges.push(edge);
this.actionDispatcher.dispatchAfterNextUpdate(
SelectAction.create({ selectedElementsIDs: [this.modelState.idProvider.getLocalId(edge) ?? edge.id] })
);
}
}
}
Expand All @@ -66,6 +77,7 @@ export class SystemDiagramCreateEdgeOperationHandler extends JsonCreateEdgeOpera
$container: relationshipRoot,
id: this.modelState.idProvider.findNextId(Relationship, source + 'To' + target),
type: '1:1',
attributes: [],
parent: { $refText: sourceNode.entity?.$refText || '' },
child: { $refText: targetNode.entity?.$refText || '' }
};
Expand Down
12 changes: 10 additions & 2 deletions packages/glsp-client/src/browser/crossmodel-diagram-module.ts
Expand Up @@ -2,7 +2,15 @@
* Copyright (c) 2024 CrossBreeze.
********************************************************************************/

import { ConsoleLogger, LogLevel, SetViewportAction, TYPES, bindAsService, configureActionHandler } from '@eclipse-glsp/client';
import {
ConsoleLogger,
LogLevel,
SetViewportAction,
TYPES,
bindAsService,
bindOrRebind,
configureActionHandler
} from '@eclipse-glsp/client';
import { TheiaGLSPSelectionForwarder } from '@eclipse-glsp/theia-integration';
import { ContainerModule, interfaces } from '@theia/core/shared/inversify';
import { GridAlignmentHandler } from './crossmodel-grid-handler';
Expand All @@ -16,7 +24,7 @@ export function createCrossModelDiagramModule(registry: interfaces.ContainerModu
rebind(TYPES.LogLevel).toConstantValue(LogLevel.warn);
bindAsService(bind, CrossModelSelectionDataService, CrossModelGLSPSelectionDataService);
bind(CrossModelTheiaGLSPSelectionForwarder).toSelf().inSingletonScope();
rebind(TheiaGLSPSelectionForwarder).toService(CrossModelTheiaGLSPSelectionForwarder);
bindOrRebind({ bind, isBound, rebind }, TheiaGLSPSelectionForwarder).toService(CrossModelTheiaGLSPSelectionForwarder);
bind(TYPES.ISnapper).to(CrossModelGridSnapper);
configureActionHandler({ bind, isBound }, SetViewportAction.KIND, GridAlignmentHandler);
registry(bind, unbind, isBound, rebind, unbindAsync, onActivation, onDeactivation);
Expand Down
Expand Up @@ -9,6 +9,7 @@ import {
Args,
Bounds,
CursorCSS,
Disposable,
EdgeCreationTool,
EdgeCreationToolMouseListener,
FeedbackEdgeEndMovingMouseListener,
Expand Down Expand Up @@ -156,9 +157,6 @@ export class MappingEdgeCreationToolMouseListener extends EdgeCreationToolMouseL
DrawFeedbackEdgeAction.create({ elementTypeId: this.triggerAction.elementTypeId, sourceId: this.source })
]);
}
[Symbol.dispose](): void {
throw new Error('Method not implemented.');
}

override mouseOver(target: GModelElement, _event: MouseEvent): Action[] {
const otherAttributeParent = revertAttributeParent(this.triggerAction.args.sourceAttributeParent);
Expand Down
@@ -0,0 +1,14 @@
/********************************************************************************
* Copyright (c) 2024 CrossBreeze.
********************************************************************************/

import { EdgeCreationTool, FeatureModule, edgeCreationToolModule, viewportModule } from '@eclipse-glsp/client';
import { SystemEdgeCreationTool } from './system-edge-creation-tool';

export const systemEdgeCreationToolModule = new FeatureModule(
(bind, unbind, isBound, rebind) => {
const context = { bind, unbind, isBound, rebind };
context.rebind(EdgeCreationTool).to(SystemEdgeCreationTool).inSingletonScope();
},
{ requires: [edgeCreationToolModule, viewportModule] }
);
@@ -0,0 +1,227 @@
/********************************************************************************
* Copyright (c) 2024 CrossBreeze.
********************************************************************************/

import {
Action,
Connectable,
CreateEdgeOperation,
CursorCSS,
Disposable,
DragAwareMouseListener,
EdgeCreationTool,
EnableDefaultToolsAction,
FeedbackEdgeEndMovingMouseListener,
GEdge,
GLSPActionDispatcher,
GModelElement,
HoverFeedbackAction,
ITypeHintProvider,
ModifyCSSFeedbackAction,
Point,
TriggerEdgeCreationAction,
cursorFeedbackAction,
findParentByFeature,
isConnectable,
isCtrlOrCmd
} from '@eclipse-glsp/client';
import {
DrawFeedbackEdgeAction,
RemoveFeedbackEdgeAction
} from '@eclipse-glsp/client/lib/features/tools/edge-creation/dangling-edge-feedback';
import { injectable } from '@theia/core/shared/inversify';

const CSS_EDGE_CREATION = 'edge-creation';

@injectable()
export class SystemEdgeCreationTool extends EdgeCreationTool {
override doEnable(): void {
const mouseMovingFeedback = new FeedbackEdgeEndMovingMouseListener(this.anchorRegistry, this.feedbackDispatcher);
const listener = new SystemEdgeCreationToolMouseListener(this.triggerAction, this.actionDispatcher, this.typeHintProvider, this);
this.toDisposeOnDisable.push(
listener,
mouseMovingFeedback,
this.mouseTool.registerListener(listener),
this.mouseTool.registerListener(mouseMovingFeedback),
this.registerFeedback([], this, [
RemoveFeedbackEdgeAction.create(),
cursorFeedbackAction(),
ModifyCSSFeedbackAction.create({ remove: [CSS_EDGE_CREATION] })
])
);
}
}

export interface ConnectionContext {
element?: GModelElement & Connectable;
canConnect?: boolean;
}

export interface DragConnectionContext {
element: GModelElement & Connectable;
dragStart: Point;
}

export class SystemEdgeCreationToolMouseListener extends DragAwareMouseListener implements Disposable {
protected source?: string;
protected target?: string;
protected proxyEdge: GEdge;

protected dragContext?: DragConnectionContext;
protected mouseMoveFeedback?: Disposable;
protected sourceFeedback?: Disposable;

constructor(
protected triggerAction: TriggerEdgeCreationAction,
protected actionDispatcher: GLSPActionDispatcher,
protected typeHintProvider: ITypeHintProvider,
protected tool: EdgeCreationTool
) {
super();
this.proxyEdge = new GEdge();
this.proxyEdge.type = triggerAction.elementTypeId;
}

protected isSourceSelected(): boolean {
return this.source !== undefined;
}

protected isTargetSelected(): boolean {
return this.target !== undefined;
}

override mouseDown(target: GModelElement, event: MouseEvent): Action[] {
const result = super.mouseDown(target, event);
if (event.button === 0 && !this.isSourceSelected()) {
// update the current target
const context = this.calculateContext(target, event);
if (context.element && context.canConnect) {
this.dragContext = { element: context.element, dragStart: { x: event.clientX, y: event.clientY } };
}
}
return result;
}

override mouseMove(target: GModelElement, event: MouseEvent): Action[] {
const result = super.mouseMove(target, event);
if (this.isMouseDrag && this.dragContext && !this.isSourceSelected()) {
const dragDistance = Point.maxDistance(this.dragContext.dragStart, { x: event.clientX, y: event.clientY });
if (dragDistance > 3) {
// assign source if possible
this.source = this.dragContext.element.id;
this.tool.registerFeedback([
DrawFeedbackEdgeAction.create({ elementTypeId: this.triggerAction.elementTypeId, sourceId: this.source })
]);
this.dragContext = undefined;
}
}
this.updateFeedback(target, event);
return result;
}

override draggingMouseUp(target: GModelElement, event: MouseEvent): Action[] {
const result = super.draggingMouseUp(target, event);
if (this.isSourceSelected()) {
const context = this.calculateContext(target, event);
if (context.element && context.canConnect) {
this.target = context.element.id;
result.push(
CreateEdgeOperation.create({
elementTypeId: this.triggerAction.elementTypeId,
sourceElementId: this.source!,
targetElementId: this.target,
args: this.triggerAction.args
})
);
if (!isCtrlOrCmd(event)) {
result.push(EnableDefaultToolsAction.create());
}
}
}
this.reinitialize();
return result;
}

override nonDraggingMouseUp(_element: GModelElement, event: MouseEvent): Action[] {
this.reinitialize();
return [EnableDefaultToolsAction.create()];
}

protected canConnect(element: GModelElement | undefined, role: 'source' | 'target'): boolean {
return (
!!element &&
!!isConnectable(element) &&
element.canConnect(this.proxyEdge, role) &&
(role !== 'target' || this.source !== element?.id)
);
}

protected updateFeedback(target: GModelElement, event: MouseEvent): void {
const context = this.calculateContext(target, event);

// source element feedback
if (this.isSourceSelected()) {
this.sourceFeedback = this.tool.registerFeedback(
[HoverFeedbackAction.create({ mouseoverElement: this.source!, mouseIsOver: true })],
this.proxyEdge,
[HoverFeedbackAction.create({ mouseoverElement: this.source!, mouseIsOver: false })]
);
}

// cursor feedback
if (!context.element || context.element?.id === this.source) {
// by default we want to use the edge creation CSS when the tool is active
this.registerFeedback(
[ModifyCSSFeedbackAction.create({ add: [CSS_EDGE_CREATION] })],
[ModifyCSSFeedbackAction.create({ remove: [CSS_EDGE_CREATION] })]
);
return;
}

if (!context.canConnect) {
this.registerFeedback([cursorFeedbackAction(CursorCSS.OPERATION_NOT_ALLOWED)], [cursorFeedbackAction()]);
return;
}

const cursorCss = this.isSourceSelected() ? CursorCSS.EDGE_CREATION_TARGET : CursorCSS.EDGE_CREATION_SOURCE;
this.registerFeedback(
[cursorFeedbackAction(cursorCss), HoverFeedbackAction.create({ mouseoverElement: context.element.id, mouseIsOver: true })],
[cursorFeedbackAction(), HoverFeedbackAction.create({ mouseoverElement: context.element.id, mouseIsOver: false })]
);
}

protected registerFeedback(feedbackActions: Action[], cleanupActions?: Action[]): Disposable {
this.mouseMoveFeedback?.dispose();
this.mouseMoveFeedback = this.tool.registerFeedback(feedbackActions, this, cleanupActions);
return this.mouseMoveFeedback;
}

protected calculateContext(target: GModelElement, event: MouseEvent, previousContext?: ConnectionContext): ConnectionContext {
const context: ConnectionContext = {};
context.element = findParentByFeature(target, isConnectable);
if (previousContext && previousContext.element === context.element) {
return previousContext;
}
if (!this.isSourceSelected()) {
context.canConnect = this.canConnect(context.element, 'source');
} else if (!this.isTargetSelected()) {
context.canConnect = this.canConnect(context.element, 'target');
} else {
context.canConnect = false;
}
return context;
}

protected reinitialize(): void {
this.source = undefined;
this.target = undefined;
this.tool.registerFeedback([RemoveFeedbackEdgeAction.create()]);
this.dragContext = undefined;
this.mouseMoveFeedback?.dispose();
this.sourceFeedback?.dispose();
}

dispose(): void {
this.reinitialize();
}
}
@@ -0,0 +1,23 @@
/********************************************************************************
* Copyright (c) 2024 CrossBreeze.
********************************************************************************/
import {
FeatureModule,
RankedSelectMouseListener,
SelectAllCommand,
SelectCommand,
SelectFeedbackCommand,
TYPES,
bindAsService,
configureCommand
} from '@eclipse-glsp/client';
import { SystemSelectTool } from './select-tool';

export const systemSelectModule = new FeatureModule((bind, _unbind, isBound) => {
const context = { bind, isBound };
configureCommand(context, SelectCommand);
configureCommand(context, SelectAllCommand);
configureCommand(context, SelectFeedbackCommand);
bindAsService(context, TYPES.IDefaultTool, SystemSelectTool);
bind(RankedSelectMouseListener).toSelf().inSingletonScope();
});
@@ -0,0 +1,25 @@
/********************************************************************************
* Copyright (c) 2024 CrossBreeze.
********************************************************************************/

import { GLSPMouseTool, RankedSelectMouseListener, Tool } from '@eclipse-glsp/client';
import { inject, injectable } from '@theia/core/shared/inversify';

@injectable()
export class SystemSelectTool implements Tool {
static ID = 'tool_system_select';

id = SystemSelectTool.ID;
isEditTool = false;

@inject(GLSPMouseTool) protected mouseTool: GLSPMouseTool;
@inject(RankedSelectMouseListener) protected listener: RankedSelectMouseListener;

enable(): void {
this.mouseTool.registerListener(this.listener);
}

disable(): void {
this.mouseTool.deregister(this.listener);
}
}

0 comments on commit d6e9ef8

Please sign in to comment.