diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index ad7714aee2..de4d55aa72 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-041] 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 @@ -49,9 +50,13 @@ - https://github.com/eclipse-sirius/sirius-components/issues/985[#985] [core] Provide a variable to detect which environment is used. The value of the variable will change to something specific for the integrating application (for example siriusWeb). This is only available for the diagram for now - https://github.com/eclipse-sirius/sirius-components/issues/1025[#1025] [diagram] Add a new API to perform tests of our layout algorithm - https://github.com/eclipse-sirius/sirius-components/issues/699[#699] [core] Provide the `IEditingContext` to find all the `RepresentationMetadata` for a specific object +<<<<<<< Upstream, based on origin/master - 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/1049[#1049] [diagram] Add border node enhancements +>>>>>>> 04da7b1 [956] Implement bordered snap to parent container === New features diff --git a/doc/adrs/041_add_border_node_snap_to_container_algorithm.adoc b/doc/adrs/041_add_border_node_snap_to_container_algorithm.adoc new file mode 100644 index 0000000000..0fa1876d50 --- /dev/null +++ b/doc/adrs/041_add_border_node_snap_to_container_algorithm.adoc @@ -0,0 +1,32 @@ += ADR-41 - 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 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 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 25% offset is used to have the border node entering the parent node. + +=== Algorithm details + +To determines the side to snap, the algorithm calculates the distance between the center of the "under moved" border node and each border segment border of the parent node. The snap is done on the closest border. + +Then the offset is applied to have the border node entering the parent node +* For north and south borders, 25% is applied and the height of the border node. +* For east and west borders, 25% is applied and the width of the border 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..bb7290bd66 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'; @@ -568,7 +568,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/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/__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 740056e71a..db257d58b4 100644 --- a/frontend/src/diagram/sprotty/convertDiagram.tsx +++ b/frontend/src/diagram/sprotty/convertDiagram.tsx @@ -25,6 +25,7 @@ import { } from 'diagram/DiagramWebSocketContainer.types'; import { ArrowStyle, + BorderNode, Diagram, Edge, EdgeStyle, @@ -120,7 +121,7 @@ const convertNode = ( parentElement.add(node); const convertedLabel = convertLabel(node, label, httpOrigin, readOnly); - (borderNodes ?? []).map((borderNode) => convertNode(node, borderNode, httpOrigin, readOnly, autoLayout)); + (borderNodes ?? []).map((borderNode) => convertBorderNode(node, borderNode, httpOrigin, readOnly, autoLayout)); (childNodes ?? []).map((childNode) => convertNode(node, childNode, httpOrigin, readOnly, autoLayout)); node.id = id; @@ -138,6 +139,32 @@ const convertNode = ( 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([ diff --git a/frontend/src/diagram/sprotty/siriusDragAndDropMouseListener.ts b/frontend/src/diagram/sprotty/siriusDragAndDropMouseListener.ts index 7484fda471..76d47c6b75 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,60 @@ 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; + if (this.isSNode(parent)) { + // Determine on which side the port should be associated + const translationPoint = { + x: newSportPosition.x - sPort.bounds.x, + y: newSportPosition.y - sPort.bounds.y, + } as Point; + const currentSPortCenter = Bounds.center(sPort.bounds); + const candidateSPortCenter = { + 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) { + return { + x: pointOnRectangleSide.x - sPort.bounds.width / 2, + y: pointOnRectangleSide.y - sPort.bounds.height + PORT_OFFSET, + }; + } else if (side === RectangleSide.south) { + return { + x: pointOnRectangleSide.x - sPort.bounds.width / 2, + y: pointOnRectangleSide.y - PORT_OFFSET, + }; + } else if (side === RectangleSide.west) { + return { + x: pointOnRectangleSide.x - sPort.bounds.width + PORT_OFFSET, + y: pointOnRectangleSide.y - sPort.bounds.height / 2, + }; + } else if (side === RectangleSide.east) { + return { + x: pointOnRectangleSide.x - PORT_OFFSET, + y: pointOnRectangleSide.y - sPort.bounds.height / 2, + }; + } + } + // the SPort is not moved + return { x: sPort.bounds.x, y: sPort.bounds.y }; + } + protected reset() { this.intialTarget = undefined; this.startingPosition = undefined; @@ -136,6 +200,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..4d362e89df --- /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; +}