diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index ad7714aee2..9e1fd5b60a 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -9,6 +9,7 @@ - [ADR-041] Add the ability to contribute additional services to the EMFQueryService - [ADR-042] Use wildcard collections instead of List in Providers - [ADR-043] Consider multiple objects as the input of a form +- [ADR-044] Use border node "snap to parent container" algorithm === Deprecation warning - [core] The various DTOs related to the creation and renaming of both documents and representations will be removed from the project at some point. The only reason why we will keep them for the moment is that some of them are used to trigger some specific behavior in the `EditingContextEventProcessor`. The split of the representation metadata should help us remove those special use cases @@ -52,6 +53,7 @@ - https://github.com/eclipse-sirius/sirius-components/issues/1054[#1054] [diagram] Add missing variables to compute the label of an edge - https://github.com/eclipse-sirius/sirius-components/issues/1063[#1063] [explorer] It is now possible to expand or collapse items in the explorer without selecting them by clicking directly on the expand/collapse arrow icon - https://github.com/eclipse-sirius/sirius-components/issues/1068[#1068] [form] Add support for displaying details on arbitrary element kinds +- https://github.com/eclipse-sirius/sirius-components/issues/956[#956] [diagram] Add the border node concept on front-end and implement the border node snap. The user can move the border node only on the side of its parent node. The border node enters its parent node with 8px. The ELK automatic layout is adapated to have the same behavior. === New features diff --git a/backend/sirius-components-diagrams-layout/src/main/java/org/eclipse/sirius/components/diagrams/layout/LayoutConfiguratorRegistry.java b/backend/sirius-components-diagrams-layout/src/main/java/org/eclipse/sirius/components/diagrams/layout/LayoutConfiguratorRegistry.java index d0bd7f0c44..19250b38d1 100644 --- a/backend/sirius-components-diagrams-layout/src/main/java/org/eclipse/sirius/components/diagrams/layout/LayoutConfiguratorRegistry.java +++ b/backend/sirius-components-diagrams-layout/src/main/java/org/eclipse/sirius/components/diagrams/layout/LayoutConfiguratorRegistry.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2019, 2021 Obeo. + * Copyright (c) 2019, 2022 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -67,6 +67,11 @@ public class LayoutConfiguratorRegistry { */ private static final int MIN_WIDTH_CONSTRAINT = 150; + /** + * The default value for port border offset. + */ + private static final double DEFAULT_PORT_BORDER_OFFSET = -8; + private final List customLayoutProviders; public LayoutConfiguratorRegistry(List customLayoutProviders) { @@ -90,7 +95,8 @@ public ISiriusWebLayoutConfigurator getDefaultLayoutConfigurator() { .setProperty(CoreOptions.NODE_SIZE_OPTIONS, EnumSet.of(SizeOptions.ASYMMETRICAL)) .setProperty(CoreOptions.NODE_SIZE_MINIMUM, new KVector(LayoutOptionValues.MIN_WIDTH_CONSTRAINT, LayoutOptionValues.MIN_HEIGHT_CONSTRAINT)) .setProperty(CoreOptions.NODE_LABELS_PLACEMENT, NodeLabelPlacement.insideTopCenter()) - .setProperty(CoreOptions.NODE_SIZE_MINIMUM, new KVector(MIN_WIDTH_CONSTRAINT, MIN_HEIGHT_CONSTRAINT)); + .setProperty(CoreOptions.NODE_SIZE_MINIMUM, new KVector(MIN_WIDTH_CONSTRAINT, MIN_HEIGHT_CONSTRAINT)) + .setProperty(CoreOptions.PORT_BORDER_OFFSET, DEFAULT_PORT_BORDER_OFFSET); configurator.configureByType(NodeType.NODE_LIST) .setProperty(CoreOptions.ALGORITHM, FixedLayouterOptions.ALGORITHM_ID) @@ -103,7 +109,8 @@ public ISiriusWebLayoutConfigurator getDefaultLayoutConfigurator() { configurator.configureByType(NodeType.NODE_IMAGE) .setProperty(CoreOptions.NODE_SIZE_CONSTRAINTS, EnumSet.of(SizeConstraint.MINIMUM_SIZE, SizeConstraint.PORT_LABELS, SizeConstraint.PORTS)) - .setProperty(CoreOptions.NODE_LABELS_PLACEMENT, NodeLabelPlacement.outsideTopCenter()); + .setProperty(CoreOptions.NODE_LABELS_PLACEMENT, NodeLabelPlacement.outsideTopCenter()) + .setProperty(CoreOptions.PORT_BORDER_OFFSET, DEFAULT_PORT_BORDER_OFFSET); // This image type does not match any diagram item. We add it to define the image size as constraint for the node image parent. configurator.configureByType(ELKDiagramConverter.DEFAULT_IMAGE_TYPE) diff --git a/doc/adrs/044_use_border_node_snap_to_container_algorithm.adoc b/doc/adrs/044_use_border_node_snap_to_container_algorithm.adoc new file mode 100644 index 0000000000..c8bf72f0a2 --- /dev/null +++ b/doc/adrs/044_use_border_node_snap_to_container_algorithm.adoc @@ -0,0 +1,30 @@ += ADR-44 - Use border node "snap to parent container" algorithm + +== Context + +On the front end, consider a diagram containing nodes with border nodes. + +The user has the abilty to move the border nodes on a side of the parent rectangle node or onto another side. + +Until now, the border node is considered as any node and consequently the user has the ability to move the border node inside the parent node and only inside. + +== Decision + +A "snap to parent rectangle" is added. +It is used by the front end code. + +Now, when the user moves the border node, it is moved only on the border of the parent node, outside the parent node. + +An 8px offset is used to have the border node entering the parent node. + +=== Algorithm details + +When the user is dragging the border node, to determine the side on which the border node will snap, the algorithm calculates the distance between the center of the dragged border node to each side segment of the parent node. The snap is done on the closest parent node side. + +Then the offset is applied on the border node to have the border node entering the parent node + +== Status + +WIP + +== Consequences \ No newline at end of file diff --git a/frontend/src/diagram/DiagramWebSocketContainer.tsx b/frontend/src/diagram/DiagramWebSocketContainer.tsx index 8cea022031..60bf319e6e 100644 --- a/frontend/src/diagram/DiagramWebSocketContainer.tsx +++ b/frontend/src/diagram/DiagramWebSocketContainer.tsx @@ -106,7 +106,7 @@ import { Toolbar } from 'diagram/Toolbar'; import { atLeastOneCanInvokeEdgeTool, canInvokeTool } from 'diagram/toolServices'; import React, { useCallback, useContext, useEffect, useRef } from 'react'; import { SelectionDialogWebSocketContainer } from 'selection/SelectionDialogWebSocketContainer'; -import { EditLabelAction, HoverFeedbackAction, SEdge, SGraph, SModelElement, SNode } from 'sprotty'; +import { EditLabelAction, HoverFeedbackAction, SEdge, SGraph, SModelElement, SNode, SPort } from 'sprotty'; import { FitToScreenAction } from 'sprotty-protocol'; import { v4 as uuid } from 'uuid'; import { RepresentationComponentProps, Selection, SelectionEntry } from 'workbench/Workbench.types'; @@ -454,7 +454,9 @@ export const DiagramWebSocketContainer = ({ const deleteElements = useCallback( (diagramElements: SModelElement[], deletionPolicy: GQLDeletionPolicy): void => { const edgeIds = diagramElements.filter((diagramElement) => diagramElement instanceof SEdge).map((elt) => elt.id); - const nodeIds = diagramElements.filter((diagramElement) => diagramElement instanceof SNode).map((elt) => elt.id); + const nodeIds = diagramElements + .filter((diagramElement) => diagramElement instanceof SNode || diagramElement instanceof SPort) + .map((elt) => elt.id); const input = { id: uuid(), @@ -568,7 +570,11 @@ export const DiagramWebSocketContainer = ({ if (selectedElement instanceof SGraph) { const { id, label, kind } = selectedElement as any; // (as any) to be removed when the proper type will be available newSelection.entries.push({ id, label, kind }); - } else if (selectedElement instanceof SNode || selectedElement instanceof SEdge) { + } else if ( + selectedElement instanceof SNode || + selectedElement instanceof SPort || + selectedElement instanceof SEdge + ) { const { id, kind, targetObjectId, targetObjectKind, targetObjectLabel } = selectedElement as any; // (as any) to be removed when the proper type will be available const semanticSelectionEntry: SelectionEntry = { diff --git a/frontend/src/diagram/palette/ContextualPalette.tsx b/frontend/src/diagram/palette/ContextualPalette.tsx index 04b0d67542..264cd01282 100644 --- a/frontend/src/diagram/palette/ContextualPalette.tsx +++ b/frontend/src/diagram/palette/ContextualPalette.tsx @@ -21,7 +21,7 @@ import { import { ContextualPaletteProps } from 'diagram/palette/ContextualPalette.types'; import { ToolSection } from 'diagram/palette/tool-section/ToolSection'; import { Tool } from 'diagram/palette/tool/Tool'; -import { Node } from 'diagram/sprotty/Diagram.types'; +import { BorderNode, Node } from 'diagram/sprotty/Diagram.types'; import { isContextualTool } from 'diagram/toolServices'; import { GQLSynchronizationPolicy } from 'index'; import React from 'react'; @@ -109,7 +109,7 @@ const isSynchronized = (diagramDescription: GQLDiagramDescription, element: SMod elementWithTarget = elementWithTarget.parent; } - if (element instanceof Node) { + if (element instanceof Node || element instanceof BorderNode) { const descriptionId = element.descriptionId; return ( findNodeDescription(diagramDescription.nodeDescriptions, [], descriptionId).synchronizationPolicy === @@ -245,7 +245,7 @@ export const ContextualPalette = ({ }); let renameEntry; - if (invokeLabelEdit) { + if (invokeLabelEdit && !(targetElement instanceof BorderNode)) { renameEntry = (
invokeLabelEdit()} /> diff --git a/frontend/src/diagram/sprotty/DependencyInjection.ts b/frontend/src/diagram/sprotty/DependencyInjection.ts index d8fbd3805d..62a079a7fb 100644 --- a/frontend/src/diagram/sprotty/DependencyInjection.ts +++ b/frontend/src/diagram/sprotty/DependencyInjection.ts @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2019, 2021 Obeo. + * Copyright (c) 2019, 2022 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -10,7 +10,7 @@ * Contributors: * Obeo - initial API and implementation *******************************************************************************/ -import { Node } from 'diagram/sprotty/Diagram.types'; +import { BorderNode, Node } from 'diagram/sprotty/Diagram.types'; import { DiagramServer, HIDE_CONTEXTUAL_TOOLBAR_ACTION, SPROTTY_DELETE_ACTION } from 'diagram/sprotty/DiagramServer'; import { SetActiveConnectorToolsAction, SetActiveToolAction } from 'diagram/sprotty/DiagramServer.types'; import { edgeCreationFeedback } from 'diagram/sprotty/edgeCreationFeedback'; @@ -119,7 +119,9 @@ const siriusWebContainerModule = new ContainerModule((bind, unbind, isBound, reb // @ts-ignore configureModelElement(context, 'node:list:item', Node, ListItemView); // @ts-ignore - configureView({ bind, isBound }, 'port:square', RectangleView); + configureModelElement(context, 'port:rectangle', BorderNode, RectangleView); + // @ts-ignore + configureModelElement(context, 'port:image', BorderNode, ImageView); configureView({ bind, isBound }, 'edge:straight', EdgeView); // @ts-ignore configureModelElement(context, 'label:inside-center', SEditableLabel, LabelView); @@ -229,9 +231,14 @@ export const createDependencyInjectionContainer = (containerId: string, getCurso } } - const findModelElementWithSemanticTarget = (element: SModelElement): SGraph | Node | SEdge | null => { - let graphicalElement: SGraph | Node | SEdge | null = null; - if (element instanceof SGraph || element instanceof Node || element instanceof SEdge) { + const findModelElementWithSemanticTarget = (element: SModelElement): SGraph | Node | BorderNode | SEdge | null => { + let graphicalElement: SGraph | Node | BorderNode | SEdge | null = null; + if ( + element instanceof SGraph || + element instanceof Node || + element instanceof BorderNode || + element instanceof SEdge + ) { graphicalElement = element; } else if (element instanceof SLabel) { graphicalElement = findModelElementWithSemanticTarget(element.parent); diff --git a/frontend/src/diagram/sprotty/Diagram.types.ts b/frontend/src/diagram/sprotty/Diagram.types.ts index b56e12fc0d..ccdfd69064 100644 --- a/frontend/src/diagram/sprotty/Diagram.types.ts +++ b/frontend/src/diagram/sprotty/Diagram.types.ts @@ -30,6 +30,16 @@ export class Node extends SNode implements WithEditableLabel { targetObjectLabel: string; } +export class BorderNode extends SPort implements WithEditableLabel { + editableLabel?: EditableLabel & Label; + descriptionId: string; + kind: string; + style: INodeStyle; + targetObjectId: string; + targetObjectKind: string; + targetObjectLabel: string; +} + export interface INodeStyle {} export class ImageNodeStyle implements INodeStyle { @@ -108,5 +118,3 @@ export class LabelStyle { strikeThrough: boolean; underline: boolean; } - -export class Port extends SPort {} diff --git a/frontend/src/diagram/sprotty/DiagramServer.tsx b/frontend/src/diagram/sprotty/DiagramServer.tsx index 2fcca0c21c..5a0aaa6ceb 100644 --- a/frontend/src/diagram/sprotty/DiagramServer.tsx +++ b/frontend/src/diagram/sprotty/DiagramServer.tsx @@ -47,6 +47,7 @@ import { SelectionResult, SGraph, SNode, + SPort, ViewportResult, } from 'sprotty'; import { @@ -430,7 +431,7 @@ export class DiagramServer extends ModelSource { }; let edgeStartPosition = { x: 0, y: 0 }; - if (element instanceof SNode) { + if (element instanceof SNode || element instanceof SPort) { edgeStartPosition = { x: (lastPositionOnDiagram.x - scroll.x) * zoom, y: (lastPositionOnDiagram.y - scroll.y) * zoom, diff --git a/frontend/src/diagram/sprotty/__tests__/borderNodes.test.ts b/frontend/src/diagram/sprotty/__tests__/borderNodes.test.ts new file mode 100644 index 0000000000..4073c0a942 --- /dev/null +++ b/frontend/src/diagram/sprotty/__tests__/borderNodes.test.ts @@ -0,0 +1,131 @@ +/******************************************************************************* + * Copyright (c) 2022 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +import { httpOrigin } from 'common/URL'; +import 'reflect-metadata'; +import { SNode } from 'sprotty'; +import { Point } from 'sprotty-protocol/'; +import { convertDiagram } from '../convertDiagram'; +import { BorderNode } from '../Diagram.types'; +import { SiriusDragAndDropMouseListener } from '../siriusDragAndDropMouseListener'; +import { siriusWebDiagram } from './siriusWebDiagram'; + +describe('Border node positioning', () => { + it('snaps the border node', () => { + const sprottyDiagram = convertDiagram(siriusWebDiagram, httpOrigin, false); + + const sprottyNode: SNode = sprottyDiagram.children[1]; + + const borderNode: BorderNode = sprottyNode.children.filter((value) => { + return value instanceof BorderNode; + })[0]; + + // border node position when its center is positionned at the parent upper left corner(origin)) + const borderNodeOrigin: Point = { x: -borderNode.size.width / 2, y: -borderNode.size.height / 2 }; + const parentWidth: number = sprottyNode.size.width; + const parentHeight: number = sprottyNode.size.height; + + const siriusDragAndDropMouseListener: SiriusDragAndDropMouseListener = new SiriusDragAndDropMouseListener(); + expect( + siriusDragAndDropMouseListener.snap({ x: borderNodeOrigin.x - 10, y: borderNodeOrigin.y - 5 }, borderNode, false) + ).toStrictEqual({ x: -52, y: -20 }); + expect( + siriusDragAndDropMouseListener.snap({ x: borderNodeOrigin.x - 10, y: borderNodeOrigin.y - 15 }, borderNode, false) + ).toStrictEqual({ x: -30, y: -32 }); + expect( + siriusDragAndDropMouseListener.snap({ x: borderNodeOrigin.x + 50, y: borderNodeOrigin.y - 25 }, borderNode, false) + ).toStrictEqual({ x: 20, y: -32 }); + expect( + siriusDragAndDropMouseListener.snap({ x: borderNodeOrigin.x + 50, y: borderNodeOrigin.y + 5 }, borderNode, false) + ).toStrictEqual({ x: 20, y: -32 }); + expect( + siriusDragAndDropMouseListener.snap( + { x: borderNodeOrigin.x + parentWidth + 5, y: borderNodeOrigin.y - 10 }, + borderNode, + false + ) + ).toStrictEqual({ x: 250, y: -32 }); + expect( + siriusDragAndDropMouseListener.snap( + { x: borderNodeOrigin.x + parentWidth + 15, y: borderNodeOrigin.y - 10 }, + borderNode, + false + ) + ).toStrictEqual({ x: 272, y: -20 }); + expect( + siriusDragAndDropMouseListener.snap( + { x: borderNodeOrigin.x + parentWidth + 15, y: borderNodeOrigin.y + 50 }, + borderNode, + false + ) + ).toStrictEqual({ x: 272, y: 30 }); + expect( + siriusDragAndDropMouseListener.snap( + { x: borderNodeOrigin.x + parentWidth - 5, y: borderNodeOrigin.y + 50 }, + borderNode, + false + ) + ).toStrictEqual({ x: 272, y: 30 }); + expect( + siriusDragAndDropMouseListener.snap( + { x: borderNodeOrigin.x + parentWidth + 15, y: borderNodeOrigin.y + parentHeight + 10 }, + borderNode, + false + ) + ).toStrictEqual({ x: 272, y: 160 }); + expect( + siriusDragAndDropMouseListener.snap( + { x: borderNodeOrigin.x + parentWidth + 10, y: borderNodeOrigin.y + parentHeight + 15 }, + borderNode, + false + ) + ).toStrictEqual({ x: 250, y: 172 }); + expect( + siriusDragAndDropMouseListener.snap( + { x: borderNodeOrigin.x + parentWidth / 2, y: borderNodeOrigin.y + parentHeight - 5 }, + borderNode, + false + ) + ).toStrictEqual({ x: 110, y: 172 }); + expect( + siriusDragAndDropMouseListener.snap( + { x: borderNodeOrigin.x + parentWidth / 2, y: borderNodeOrigin.y + parentHeight + 5 }, + borderNode, + false + ) + ).toStrictEqual({ x: 110, y: 172 }); + + expect( + siriusDragAndDropMouseListener.snap({ x: borderNodeOrigin.x - 5, y: borderNodeOrigin.y + 10 }, borderNode, false) + ).toStrictEqual({ x: -52, y: -10 }); + expect( + siriusDragAndDropMouseListener.snap({ x: borderNodeOrigin.x - 10, y: borderNodeOrigin.y + 5 }, borderNode, false) + ).toStrictEqual({ x: -52, y: -15 }); + expect( + siriusDragAndDropMouseListener.snap( + { x: borderNodeOrigin.x + 10, y: borderNodeOrigin.y + parentHeight / 2 }, + borderNode, + false + ) + ).toStrictEqual({ x: -52, y: 70 }); + expect( + siriusDragAndDropMouseListener.snap( + { x: borderNodeOrigin.x - 10, y: borderNodeOrigin.y + parentHeight / 2 }, + borderNode, + false + ) + ).toStrictEqual({ x: -52, y: 70 }); + expect( + siriusDragAndDropMouseListener.snap({ x: borderNodeOrigin.x - 10, y: borderNodeOrigin.y - 5 }, borderNode, false) + ).toStrictEqual({ x: -52, y: -20 }); + }); +}); diff --git a/frontend/src/diagram/sprotty/__tests__/siriusWebDiagram.ts b/frontend/src/diagram/sprotty/__tests__/siriusWebDiagram.ts index eeb6065fb6..7374d9156c 100644 --- a/frontend/src/diagram/sprotty/__tests__/siriusWebDiagram.ts +++ b/frontend/src/diagram/sprotty/__tests__/siriusWebDiagram.ts @@ -134,8 +134,8 @@ export const siriusWebDiagram: GQLDiagram = { y: 64.34101572060084, }, size: { - width: 286, - height: 181.6982421875, + width: 280, + height: 180, }, borderNodes: [ { @@ -181,8 +181,8 @@ export const siriusWebDiagram: GQLDiagram = { y: 139.6982421875, }, size: { - width: 30, - height: 30, + width: 60, + height: 40, }, borderNodes: [], childNodes: [], diff --git a/frontend/src/diagram/sprotty/convertDiagram.tsx b/frontend/src/diagram/sprotty/convertDiagram.tsx index c721b4cb9b..1937d97751 100644 --- a/frontend/src/diagram/sprotty/convertDiagram.tsx +++ b/frontend/src/diagram/sprotty/convertDiagram.tsx @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2019, 2021 Obeo. + * Copyright (c) 2019, 2022 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -25,6 +25,7 @@ import { } from 'diagram/DiagramWebSocketContainer.types'; import { ArrowStyle, + BorderNode, Diagram, Edge, EdgeStyle, @@ -53,6 +54,7 @@ import { popupFeature, selectFeature, SLabel, + SParentElement, viewportFeature, withEditLabelFeature, } from 'sprotty'; @@ -87,13 +89,19 @@ export const convertDiagram = (gqlDiagram: GQLDiagram, httpOrigin: string, readO diagram.targetObjectId = targetObjectId; diagram.features = createFeatureSet([hoverFeedbackFeature, viewportFeature]); - nodes.map((node) => convertNode(node, httpOrigin, readOnly, autoLayout)).map((node) => diagram.add(node)); + nodes.map((node) => convertNode(diagram, node, httpOrigin, readOnly, autoLayout)); edges.map((edge) => convertEdge(diagram, edge, httpOrigin, readOnly)); return diagram; }; -const convertNode = (gqlNode: GQLNode, httpOrigin: string, readOnly: boolean, autoLayout: boolean): Node => { +const convertNode = ( + parentElement: SParentElement, + gqlNode: GQLNode, + httpOrigin: string, + readOnly: boolean, + autoLayout: boolean +): Node => { const { id, label, @@ -109,15 +117,13 @@ const convertNode = (gqlNode: GQLNode, httpOrigin: string, readOnly: boolean, au childNodes, } = gqlNode; - const convertedLabel = convertLabel(label, httpOrigin, readOnly); - const convertedBorderNodes = (borderNodes ?? []).map((borderNode) => - convertNode(borderNode, httpOrigin, readOnly, autoLayout) - ); - const convertedChildNodes = (childNodes ?? []).map((childNode) => - convertNode(childNode, httpOrigin, readOnly, autoLayout) - ); - const node: Node = new Node(); + parentElement.add(node); + + const convertedLabel = convertLabel(node, label, httpOrigin, readOnly); + (borderNodes ?? []).map((borderNode) => convertBorderNode(node, borderNode, httpOrigin, readOnly, autoLayout)); + (childNodes ?? []).map((childNode) => convertNode(node, childNode, httpOrigin, readOnly, autoLayout)); + node.id = id; node.type = type; node.kind = `siriusComponents://graphical?representationType=Diagram&type=Node`; @@ -130,10 +136,35 @@ const convertNode = (gqlNode: GQLNode, httpOrigin: string, readOnly: boolean, au node.position = position; node.size = size; node.features = handleNodeFeatures(gqlNode, readOnly, autoLayout); - node.children = [convertedLabel, ...convertedBorderNodes, ...convertedChildNodes]; return node; }; +const convertBorderNode = ( + parentElement: SParentElement, + gqlNode: GQLNode, + httpOrigin: string, + readOnly: boolean, + autoLayout: boolean +): BorderNode => { + const { id, descriptionId, type, targetObjectId, targetObjectKind, targetObjectLabel, size, position, style } = + gqlNode; + + const node: BorderNode = new BorderNode(); + parentElement.add(node); + + node.id = id; + node.type = type.replace('node:', 'port:'); + node.kind = `siriusComponents://graphical?representationType=Diagram&type=Node`; + node.descriptionId = descriptionId; + node.style = convertNodeStyle(style, httpOrigin); + node.targetObjectId = targetObjectId; + node.targetObjectKind = targetObjectKind; + node.targetObjectLabel = targetObjectLabel; + node.position = position; + node.size = size; + node.features = handleNodeFeatures(gqlNode, readOnly, autoLayout); + return node; +}; const handleNodeFeatures = (gqlNode: GQLNode, readOnly: boolean, autoLayout: boolean): FeatureSet => { const features = new Set([ @@ -172,10 +203,16 @@ const handleNodeFeatures = (gqlNode: GQLNode, readOnly: boolean, autoLayout: boo return features; }; -const convertLabel = (gqlLabel: GQLLabel, httpOrigin: string, readOnly: boolean): Label => { +const convertLabel = ( + parentElement: SParentElement, + gqlLabel: GQLLabel, + httpOrigin: string, + readOnly: boolean +): Label => { const { id, text, type, style, alignment, position, size } = gqlLabel; const label: Label = new Label(); + parentElement.add(label); label.id = id; label.text = text; label.type = type; @@ -274,10 +311,6 @@ const convertEdge = (diagram: Diagram, gqlEdge: GQLEdge, httpOrigin: string, rea style, } = gqlEdge; - const convertedBeginLabel: Label | null = beginLabel ? convertLabel(beginLabel, httpOrigin, readOnly) : null; - const convertedCenterLabel: Label | null = centerLabel ? convertLabel(centerLabel, httpOrigin, readOnly) : null; - const convertedEndLabel: Label | null = endLabel ? convertLabel(endLabel, httpOrigin, readOnly) : null; - const edgeStyle = new EdgeStyle(); edgeStyle.color = style.color; edgeStyle.size = style.size; @@ -287,6 +320,15 @@ const convertEdge = (diagram: Diagram, gqlEdge: GQLEdge, httpOrigin: string, rea const edge = new Edge(); diagram.add(edge); + + if (beginLabel) { + convertLabel(edge, beginLabel, httpOrigin, readOnly); + } + const convertedCenterLabel: Label | null = centerLabel ? convertLabel(edge, centerLabel, httpOrigin, readOnly) : null; + if (endLabel) { + convertLabel(edge, endLabel, httpOrigin, readOnly); + } + edge.id = id; edge.type = type; edge.kind = `siriusComponents://graphical?representationType=Diagram&type=Edge`; @@ -301,16 +343,6 @@ const convertEdge = (diagram: Diagram, gqlEdge: GQLEdge, httpOrigin: string, rea edge.targetObjectLabel = targetObjectLabel; edge.features = handleEdgeFeatures(readOnly); - if (convertedBeginLabel) { - edge.add(convertedBeginLabel); - } - if (convertedCenterLabel) { - edge.add(convertedCenterLabel); - } - if (convertedEndLabel) { - edge.add(convertedEndLabel); - } - return edge; }; diff --git a/frontend/src/diagram/sprotty/siriusDragAndDropMouseListener.ts b/frontend/src/diagram/sprotty/siriusDragAndDropMouseListener.ts index 7484fda471..591d0b658f 100644 --- a/frontend/src/diagram/sprotty/siriusDragAndDropMouseListener.ts +++ b/frontend/src/diagram/sprotty/siriusDragAndDropMouseListener.ts @@ -10,9 +10,17 @@ * Contributors: * Obeo - initial API and implementation *******************************************************************************/ -import { findParentByFeature, isViewport, MoveMouseListener, SModelElement, SNode } from 'sprotty'; +import { findParentByFeature, isViewport, MoveMouseListener, SModelElement, SNode, SPort } from 'sprotty'; import { Action, Dimension, Point } from 'sprotty-protocol'; +import { Bounds } from 'sprotty-protocol/'; import { ElementResize, ResizeAction } from './resize/siriusResize'; +import { snapToRectangle } from './utils/geometry'; +import { RectangleSide } from './utils/geometry.types'; + +/** + * The offset of the port inside its container. It should be the same value as the ELK default value. + */ +const PORT_OFFSET = 8; /** * A common listener for drag and drop actions. This class allows to enter in resize or move mode. @@ -83,10 +91,12 @@ export class SiriusDragAndDropMouseListener extends MoveMouseListener { /** * We override the snap method to prevent moving an element out of the container. */ - protected snap(position: Point, element: SModelElement, isSnap: boolean): Point { + public snap(position: Point, element: SModelElement, isSnap: boolean): Point { let newPosition = super.snap(position, element, isSnap); if (this.isSNode(element)) { - return this.getValidPosition(element, newPosition); + return this.getValidChildPosition(element, newPosition); + } else if (this.isSPort(element)) { + return this.getValidPortPosition(element, newPosition); } return newPosition; } @@ -96,7 +106,7 @@ export class SiriusDragAndDropMouseListener extends MoveMouseListener { * @param element the element currently moved. * @param position the new candidate position. */ - private getValidPosition(element: SNode, position: Point): Point { + private getValidChildPosition(element: SNode, position: Point): Point { const parent = element.parent; if (this.isSNode(parent)) { const bottomRight = { @@ -116,6 +126,64 @@ export class SiriusDragAndDropMouseListener extends MoveMouseListener { return position; } + /** + * Provides the position on the parent bounding box's border. + * @param sPort the sPort currently moved. + * @param position the new candidate position of the SPort upper left corner. + * @returns the real position of the port. + */ + private getValidPortPosition(sPort: SPort, newSportPosition: Point): Point { + const parent = sPort.parent; + + // by default, the SPort is not moved + let portPosition: Point = { x: sPort.bounds.x, y: sPort.bounds.y }; + + if (this.isSNode(parent)) { + // Determine on which side the port should be associated + const translationPoint: Point = { + x: newSportPosition.x - sPort.bounds.x, + y: newSportPosition.y - sPort.bounds.y, + }; + const currentSPortCenter: Point = Bounds.center(sPort.bounds); + const candidateSPortCenter: Point = { + x: currentSPortCenter.x + translationPoint.x, + y: currentSPortCenter.y + translationPoint.y, + }; + + const { pointOnRectangleSide, side } = snapToRectangle(candidateSPortCenter, { + x: 0, // because newSportPosition has coordinates relative to parent + y: 0, + width: parent.bounds.width, + height: parent.bounds.height, + }); + + // Move the port according to the offset and the side position + if (side === RectangleSide.north) { + portPosition = { + x: pointOnRectangleSide.x - sPort.bounds.width / 2, + y: pointOnRectangleSide.y - sPort.bounds.height + PORT_OFFSET, + }; + } else if (side === RectangleSide.south) { + portPosition = { + x: pointOnRectangleSide.x - sPort.bounds.width / 2, + y: pointOnRectangleSide.y - PORT_OFFSET, + }; + } else if (side === RectangleSide.west) { + portPosition = { + x: pointOnRectangleSide.x - sPort.bounds.width + PORT_OFFSET, + y: pointOnRectangleSide.y - sPort.bounds.height / 2, + }; + } else if (side === RectangleSide.east) { + portPosition = { + x: pointOnRectangleSide.x - PORT_OFFSET, + y: pointOnRectangleSide.y - sPort.bounds.height / 2, + }; + } + } + + return portPosition; + } + protected reset() { this.intialTarget = undefined; this.startingPosition = undefined; @@ -136,6 +204,10 @@ export class SiriusDragAndDropMouseListener extends MoveMouseListener { return element instanceof SNode; } + protected isSPort(element: SModelElement): element is SPort { + return element instanceof SPort; + } + /** * Computes the potential new position and new size of the element being resized. * It is only "potential" because the ResizeAction can prevent the resize. diff --git a/frontend/src/diagram/sprotty/utils/geometry.ts b/frontend/src/diagram/sprotty/utils/geometry.ts new file mode 100644 index 0000000000..c3a2122fdb --- /dev/null +++ b/frontend/src/diagram/sprotty/utils/geometry.ts @@ -0,0 +1,126 @@ +/******************************************************************************* + * Copyright (c) 2022 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +import { Bounds, Point } from 'sprotty-protocol/'; +import { RectangleSide, SnapPointToRectangleInfo, SnapToRectangleInfo } from './geometry.types'; + +/** + * Returns the distance between the point p and the segment made of p1 and p2 + * @param p the point + * @param p1 the first point of the segment + * @param p2 the second point of the segment + */ +export const closestPointOnSegment = (p: Point, p1: Point, p2: Point): Point => { + const p1pVectordx = p.x - p1.x; + const p1pVectordy = p.y - p1.y; + const p2p1Vectordx = p2.x - p1.x; + const p2p1Vectordy = p2.y - p1.y; + + const dot = p1pVectordx * p2p1Vectordx + p1pVectordy * p2p1Vectordy; + const lengthSquare = p2p1Vectordx * p2p1Vectordx + p2p1Vectordy * p2p1Vectordy; + // param is the projection distance from p1 on the segment. This is the fraction of the segment that p is closest to. + let param = -1; + if (lengthSquare != 0) { + //if the line has 0 length + param = dot / lengthSquare; + } + + let nearestPoint: Point = p1; + + if (param < 0) { + // The point is not "in front of" the segment. It is "away" on the p1 side + nearestPoint = { x: p1.x, y: p1.y }; + } else if (param > 1) { + // The point is not "in front of" the segment. It is "away" on the p1 side + nearestPoint = { x: p2.x, y: p2.y }; + } else { + // The point is "in front of" the segment + // get the projection of p on the segment + nearestPoint = { x: p1.x + param * p2p1Vectordx, y: p1.y + param * p2p1Vectordy }; + } + + return nearestPoint; +}; + +/** + * Returns a point on the rectangle that is the closest from the given p point. + */ +export const snapToRectangle = (point: Point, rectangle: Bounds): SnapToRectangleInfo => { + const upperLeftCorner: Point = { x: rectangle.x, y: rectangle.y }; + const upperRightCorner: Point = { x: rectangle.x + rectangle.width, y: rectangle.y }; + const bottomLeftCorner: Point = { x: rectangle.x, y: rectangle.y + rectangle.height }; + const bottomRightCorner: Point = { x: rectangle.x + rectangle.width, y: rectangle.y + rectangle.height }; + + let closestPointInfo = computeClosestPoint(null, point, upperLeftCorner, upperRightCorner, RectangleSide.north); + closestPointInfo = computeClosestPoint( + closestPointInfo, + point, + upperRightCorner, + bottomRightCorner, + RectangleSide.east + ); + closestPointInfo = computeClosestPoint( + closestPointInfo, + point, + bottomRightCorner, + bottomLeftCorner, + RectangleSide.south + ); + closestPointInfo = computeClosestPoint( + closestPointInfo, + point, + bottomLeftCorner, + upperLeftCorner, + RectangleSide.west + ); + + return { pointOnRectangleSide: closestPointInfo.pointOnSegment, side: closestPointInfo.side }; +}; + +const computeClosestPoint = ( + currentClosestPointInfo: SnapPointToRectangleInfo, + p: Point, + p1: Point, + p2: Point, + side: RectangleSide +): SnapPointToRectangleInfo => { + const pointOnSegment: Point = closestPointOnSegment(p, p1, p2); + let distanceSquare = getDistanceSquare(p, pointOnSegment); + + let updateTheClosestSide = false; + if (currentClosestPointInfo === null) { + updateTheClosestSide = true; + } else if (distanceSquare <= currentClosestPointInfo.distanceSquare) { + if (currentClosestPointInfo.distanceSquare === distanceSquare) { + // Manage the case when p is outside the rectangle in a corner to get the best side + if (side === RectangleSide.north || side === RectangleSide.south) { + updateTheClosestSide = Math.abs(p.y - pointOnSegment.y) > Math.abs(p.x - pointOnSegment.x); + } else if (side === RectangleSide.west || side === RectangleSide.east) { + updateTheClosestSide = Math.abs(p.y - pointOnSegment.y) < Math.abs(p.x - pointOnSegment.x); + } + } else { + updateTheClosestSide = true; + } + } + + if (updateTheClosestSide) { + return { pointOnSegment, distanceSquare, side }; + } else { + return currentClosestPointInfo; + } +}; + +const getDistanceSquare = (p1: Point, p2: Point): number => { + var dx = p1.x - p2.x; + var dy = p1.y - p2.y; + return dx * dx + dy * dy; +}; diff --git a/frontend/src/diagram/sprotty/utils/geometry.types.ts b/frontend/src/diagram/sprotty/utils/geometry.types.ts new file mode 100644 index 0000000000..80c25f80d0 --- /dev/null +++ b/frontend/src/diagram/sprotty/utils/geometry.types.ts @@ -0,0 +1,30 @@ +/******************************************************************************* + * Copyright (c) 2022 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +import { Point } from 'sprotty-protocol'; + +export enum RectangleSide { + 'north', + 'south', + 'west', + 'east', +} +export interface SnapToRectangleInfo { + pointOnRectangleSide: Point; + side: RectangleSide; +} + +export interface SnapPointToRectangleInfo { + pointOnSegment: Point; + distanceSquare: number; + side: RectangleSide; +}