This is an automated email from the ASF dual-hosted git repository.
jomarko pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-kie-tools.git
The following commit(s) were added to refs/heads/main by this push:
new c844015d911 [incubator-kie-tools#3845] [BPMN Editor] Re-anchor
connections to the facing border side when moving nodes (#3652)
c844015d911 is described below
commit c844015d911cf2c239581984d49458dfee0c9524
Author: ch-lukas <[email protected]>
AuthorDate: Wed Nov 4 20:13:25 2026 +1100
[incubator-kie-tools#3845] [BPMN Editor] Re-anchor connections to the
facing border side when moving nodes (#3652)
---
.../bpmn-editor/src/mutations/repositionNode.ts | 56 ++++++++++++-
.../ConnectionReanchor.stories.tsx | 96 ++++++++++++++++++++++
.../connectionReanchorOnNodeMove.spec.ts | 91 ++++++++++++++++++++
.../edges/getSnappedMultiPointAnchoredEdgePath.tsx | 10 +--
.../xyflow-react-kie-diagram/src/maths/DcMaths.ts | 18 +++-
5 files changed, 261 insertions(+), 10 deletions(-)
diff --git a/packages/bpmn-editor/src/mutations/repositionNode.ts
b/packages/bpmn-editor/src/mutations/repositionNode.ts
index d009c5c8a15..c28933ef580 100644
--- a/packages/bpmn-editor/src/mutations/repositionNode.ts
+++ b/packages/bpmn-editor/src/mutations/repositionNode.ts
@@ -20,11 +20,30 @@
import { switchExpression } from "@kie-tools-core/switch-expression-ts";
import { BPMN20__tDefinitions, BPMNDI__BPMNEdge } from
"@kie-tools/bpmn-marshaller/dist/schemas/bpmn-2_0/ts-gen/types";
import { DC__Shape } from
"@kie-tools/xyflow-react-kie-diagram/dist/maths/model";
+import {
+ Bounds,
+ getBoundsCenterPoint,
+ getPositionalHandlePosition,
+} from "@kie-tools/xyflow-react-kie-diagram/dist/maths/Maths";
import { BpmnNodeType } from "../diagram/BpmnDiagramDomain";
import { Normalized } from "../normalization/normalize";
import { addOrGetProcessAndDiagramElements } from
"./addOrGetProcessAndDiagramElements";
import { updateEdgeWaypointsKeepingLabelAttached } from
"./repositionEdgeLabel";
+function getShapeBoundsById(diagramElements: { "@_id"?: string }[], shapeId:
string | undefined): Bounds | undefined {
+ if (!shapeId) {
+ return undefined;
+ }
+ const bounds = (
+ diagramElements.find((e: any) => e.__$$element === "bpmndi:BPMNShape" &&
e["@_id"] === shapeId) as
+ | Normalized<DC__Shape>
+ | undefined
+ )?.["dc:Bounds"];
+ return bounds
+ ? { x: bounds["@_x"], y: bounds["@_y"], width: bounds["@_width"], height:
bounds["@_height"] }
+ : undefined;
+}
+
export function repositionNode({
definitions,
__readonly_labelReferenceDefinitions,
@@ -81,6 +100,19 @@ export function repositionNode({
throw new Error(`BPMN MUTATION: Unknown type of node position change
'${(__readonly_change as any).type}'.`);
}
+ const movedNodeBoundsNew: Bounds = {
+ x: shapeBounds["@_x"],
+ y: shapeBounds["@_y"],
+ width: shapeBounds["@_width"],
+ height: shapeBounds["@_height"],
+ };
+ const movedNodeBoundsOld: Bounds = {
+ ...movedNodeBoundsNew,
+ x: (movedNodeBoundsNew.x ?? 0) - deltaX,
+ y: (movedNodeBoundsNew.y ?? 0) - deltaY,
+ };
+ const movedNodeCenterNew = getBoundsCenterPoint(movedNodeBoundsNew);
+
const offsetEdges = (args: { edgeIndexes: number[]; waypoint: "last" |
"first" }) => {
for (const edgeIndex of args.edgeIndexes) {
const edge = diagramElements[edgeIndex] as Normalized<BPMNDI__BPMNEdge>
| undefined;
@@ -90,6 +122,21 @@ export function repositionNode({
const isEdgeSelected =
__readonly_change.selectedEdges.indexOf(edge["@_bpmnElement"]!) >= 0;
+ const endpointIndex = args.waypoint === "first" ? 0 :
edge["di:waypoint"].length - 1;
+
+ // Reset the anchor to auto (node center) only when the node crossed
into a different zone
+ // relative to the connected node. Same side/zone -> keep the (possibly
pinned) anchor.
+ const otherNodeBounds = getShapeBoundsById(
+ diagramElements,
+ args.waypoint === "first" ? edge["@_targetElement"] :
edge["@_sourceElement"]
+ );
+ let shouldResetEndpointToAuto = false;
+ if (otherNodeBounds) {
+ const [, , zoneBefore] =
getPositionalHandlePosition(movedNodeBoundsOld, otherNodeBounds, undefined);
+ const [, , zoneAfter] =
getPositionalHandlePosition(movedNodeBoundsNew, otherNodeBounds, undefined);
+ shouldResetEndpointToAuto = zoneBefore !== zoneAfter;
+ }
+
const waypointIndexes = switchExpression(args.waypoint, {
first: isEdgeSelected
? arrayRange(0, edge["di:waypoint"].length - 2) // All except last
element
@@ -112,8 +159,13 @@ export function repositionNode({
}
const w = edge["di:waypoint"][wi];
- w["@_x"] += deltaX;
- w["@_y"] += deltaY;
+ if (wi === endpointIndex && shouldResetEndpointToAuto) {
+ w["@_x"] = movedNodeCenterNew["@_x"];
+ w["@_y"] = movedNodeCenterNew["@_y"];
+ } else {
+ w["@_x"] += deltaX;
+ w["@_y"] += deltaY;
+ }
}
},
referenceElements?.find((e) => e.__$$element === "bpmndi:BPMNEdge" &&
e["@_id"] === edge["@_id"]) as
diff --git
a/packages/bpmn-editor/stories/misc/connectionReanchor/ConnectionReanchor.stories.tsx
b/packages/bpmn-editor/stories/misc/connectionReanchor/ConnectionReanchor.stories.tsx
new file mode 100644
index 00000000000..befb721984e
--- /dev/null
+++
b/packages/bpmn-editor/stories/misc/connectionReanchor/ConnectionReanchor.stories.tsx
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { Meta, StoryObj } from "@storybook/react";
+import { getMarshaller } from "@kie-tools/bpmn-marshaller";
+import { BpmnEditorWrapper, StorybookBpmnEditorProps } from
"../../bpmnEditorStoriesWrapper";
+import { BpmnEditor, BpmnEditorProps } from "../../../src/BpmnEditor";
+
+// Start -> First Function -> Second Function -> End. First Function's
incoming flow is pinned to its
+// left border and its outgoing flow to its right border (see the waypoints
below).
+const startToEndProcess = `<?xml version="1.0" encoding="UTF-8"?>
+<definitions
+ xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
+ xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
+ xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
+ xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
+ targetNamespace="https://kie.apache.org/bpmn/connection-reanchor"
+ id="connection-reanchor-definitions">
+ <process id="connection_reanchor_process" name="Connection Reanchor"
isExecutable="true">
+ <startEvent id="StartEvent_1" name="Start" />
+ <task id="Task_1" name="First Function" />
+ <task id="Task_2" name="Second Function" />
+ <endEvent id="EndEvent_1" name="End" />
+ <sequenceFlow id="Flow_1" sourceRef="StartEvent_1" targetRef="Task_1" />
+ <sequenceFlow id="Flow_2" sourceRef="Task_1" targetRef="Task_2" />
+ <sequenceFlow id="Flow_3" sourceRef="Task_2" targetRef="EndEvent_1" />
+ </process>
+ <bpmndi:BPMNDiagram id="Diagram_1">
+ <bpmndi:BPMNPlane id="Plane_1" bpmnElement="connection_reanchor_process">
+ <bpmndi:BPMNShape id="Shape_StartEvent_1" bpmnElement="StartEvent_1">
+ <dc:Bounds x="100" y="200" width="56" height="56" />
+ </bpmndi:BPMNShape>
+ <bpmndi:BPMNShape id="Shape_Task_1" bpmnElement="Task_1">
+ <dc:Bounds x="240" y="188" width="160" height="80" />
+ </bpmndi:BPMNShape>
+ <bpmndi:BPMNShape id="Shape_Task_2" bpmnElement="Task_2">
+ <dc:Bounds x="480" y="188" width="160" height="80" />
+ </bpmndi:BPMNShape>
+ <bpmndi:BPMNShape id="Shape_EndEvent_1" bpmnElement="EndEvent_1">
+ <dc:Bounds x="720" y="200" width="56" height="56" />
+ </bpmndi:BPMNShape>
+ <bpmndi:BPMNEdge id="Edge_Flow_1" bpmnElement="Flow_1"
sourceElement="Shape_StartEvent_1" targetElement="Shape_Task_1">
+ <di:waypoint x="128" y="228" />
+ <di:waypoint x="240" y="228" />
+ </bpmndi:BPMNEdge>
+ <bpmndi:BPMNEdge id="Edge_Flow_2" bpmnElement="Flow_2"
sourceElement="Shape_Task_1" targetElement="Shape_Task_2">
+ <di:waypoint x="400" y="228" />
+ <di:waypoint x="480" y="228" />
+ </bpmndi:BPMNEdge>
+ <bpmndi:BPMNEdge id="Edge_Flow_3" bpmnElement="Flow_3"
sourceElement="Shape_Task_2" targetElement="Shape_EndEvent_1">
+ <di:waypoint x="640" y="228" />
+ <di:waypoint x="720" y="228" />
+ </bpmndi:BPMNEdge>
+ </bpmndi:BPMNPlane>
+ </bpmndi:BPMNDiagram>
+</definitions>`;
+
+const meta: Meta<BpmnEditorProps> = {
+ title: "Misc/Connection Reanchor",
+ component: BpmnEditor,
+ includeStories: /^[A-Z]/,
+};
+
+export default meta;
+type Story = StoryObj<StorybookBpmnEditorProps>;
+
+const marshaller = getMarshaller(startToEndProcess, { upgradeTo: "latest" });
+const model = marshaller.parser.parse();
+
+export const StartToEnd: Story = {
+ render: (args) => BpmnEditorWrapper(),
+ args: {
+ model: model,
+ originalVersion: "2.0",
+ externalContextDescription: "The Storybook for the BPMN Editor",
+ externalContextName: "Apache KIE :: BPMN Editor :: Storybook",
+ issueTrackerHref: "",
+ xml: marshaller.builder.build(model),
+ },
+};
diff --git
a/packages/bpmn-editor/tests-e2e/flowElements/connectionReanchorOnNodeMove.spec.ts
b/packages/bpmn-editor/tests-e2e/flowElements/connectionReanchorOnNodeMove.spec.ts
new file mode 100644
index 00000000000..cd234a3d4a4
--- /dev/null
+++
b/packages/bpmn-editor/tests-e2e/flowElements/connectionReanchorOnNodeMove.spec.ts
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { test, expect } from "../__fixtures__/base";
+import { JsonModel } from "../__fixtures__/jsonModel";
+import { NodeType } from "../__fixtures__/nodes";
+
+// 0 = node's left border, 0.5 = centre (auto-anchored), 1 = right border.
+async function getEndpointRelativeX(args: {
+ jsonModel: JsonModel;
+ nodeId: string;
+ direction: "incoming" | "outgoing";
+}): Promise<number> {
+ const flows = await args.jsonModel.getSequenceFlows();
+ const flow =
+ args.direction === "incoming"
+ ? flows.find((f) => f["@_targetRef"] === args.nodeId)
+ : flows.find((f) => f["@_sourceRef"] === args.nodeId);
+ expect(flow).toBeTruthy();
+
+ const elements = (await args.jsonModel.getPlane())?.["di:DiagramElement"] ??
[];
+ const shape = elements.find(
+ (e: any) => e.__$$element === "bpmndi:BPMNShape" && e["@_bpmnElement"] ===
args.nodeId
+ ) as any;
+ const edge = elements.find(
+ (e: any) => e.__$$element === "bpmndi:BPMNEdge" && e["@_bpmnElement"] ===
flow!["@_id"]
+ ) as any;
+ expect(shape).toBeTruthy();
+ expect(edge).toBeTruthy();
+
+ const bounds = shape["dc:Bounds"];
+ const waypoints = edge["di:waypoint"];
+ const endpoint = args.direction === "incoming" ? waypoints[waypoints.length
- 1] : waypoints[0];
+
+ return (endpoint["@_x"] - bounds["@_x"]) / bounds["@_width"];
+}
+
+test.beforeEach(async ({ page, baseURL }) => {
+ await
page.goto(`${baseURL}/iframe.html?args=&id=misc-connection-reanchor--start-to-end&viewMode=story`);
+ await
expect(page.getByTestId("kie-bpmn-editor--diagram-container")).toBeVisible();
+});
+
+test.describe("Connection - re-anchor on node move", () => {
+ test("should re-optimise both incoming and outgoing connections when the
task crosses into a different zone", async ({
+ nodes,
+ jsonModel,
+ }) => {
+ const taskId = await nodes.getId({ name: "First Function" });
+
+ // Nudge within the same zones - pinned anchors are kept (also commits a
change so the model reads).
+ const box = await nodes.getNodeBounds({ name: "First Function" });
+ await nodes.dragNodeToPosition({
+ name: "First Function",
+ toPosition: { x: box.x + box.width / 2 + 40, y: box.y + box.height / 2 },
+ });
+ expect(await getEndpointRelativeX({ jsonModel, nodeId: taskId, direction:
"incoming" })).toBeLessThan(0.2);
+ expect(await getEndpointRelativeX({ jsonModel, nodeId: taskId, direction:
"outgoing" })).toBeGreaterThan(0.8);
+
+ // Move it below both neighbours - both endpoints cross into a new zone
and reset to auto.
+ const startId = await nodes.getIdByType(NodeType.START_EVENT);
+ const startBox = await nodes.getNodeBounds({ id: startId });
+ await nodes.dragNodeToPosition({
+ name: "First Function",
+ toPosition: { x: startBox.x + startBox.width / 2, y: startBox.y +
startBox.height + 350 },
+ });
+
+ const incomingAfter = await getEndpointRelativeX({ jsonModel, nodeId:
taskId, direction: "incoming" });
+ expect(incomingAfter).toBeGreaterThan(0.35);
+ expect(incomingAfter).toBeLessThan(0.65);
+
+ const outgoingAfter = await getEndpointRelativeX({ jsonModel, nodeId:
taskId, direction: "outgoing" });
+ expect(outgoingAfter).toBeGreaterThan(0.35);
+ expect(outgoingAfter).toBeLessThan(0.65);
+ });
+});
diff --git
a/packages/xyflow-react-kie-diagram/src/edges/getSnappedMultiPointAnchoredEdgePath.tsx
b/packages/xyflow-react-kie-diagram/src/edges/getSnappedMultiPointAnchoredEdgePath.tsx
index 96ce90b6ce5..e20b684b253 100644
---
a/packages/xyflow-react-kie-diagram/src/edges/getSnappedMultiPointAnchoredEdgePath.tsx
+++
b/packages/xyflow-react-kie-diagram/src/edges/getSnappedMultiPointAnchoredEdgePath.tsx
@@ -21,7 +21,7 @@ import { switchExpression } from
"@kie-tools-core/switch-expression-ts";
import { DC__Point, DC__Shape } from "../maths/model";
import { SnapGrid, snapPoint } from "../snapgrid/SnapGrid";
import { PositionalNodeHandleId } from "../nodes/PositionalNodeHandles";
-import { getHandlePosition, getLineRectangleIntersectionPoint, pointsToPath }
from "../maths/DcMaths";
+import { getBorderMidpointTowards, getHandlePosition, pointsToPath } from
"../maths/DcMaths";
import { Bounds, getBoundsCenterPoint, getDiscretelyAutoPositionedEdgeParams }
from "../maths/Maths";
import { AutoPositionedEdgeMarker } from "./AutoPositionedEdgeMarker";
@@ -142,7 +142,6 @@ export function getSnappedHandlePosition(
snappedSecondWaypoint: DC__Point,
handlePosition: PositionalNodeHandleId
): DC__Point {
- const centerHandleWaypoint = getBoundsCenterPoint(snappedNode);
const nodeRectangle = {
x: snappedNode.x ?? 0,
y: snappedNode.y ?? 0,
@@ -161,10 +160,7 @@ export function getSnappedHandlePosition(
"@_y": nodeRectangle.y + nodeRectangle.height,
},
[PositionalNodeHandleId.Left]: { "@_x": nodeRectangle.x, "@_y":
nodeRectangle.y + nodeRectangle.height / 2 },
- [PositionalNodeHandleId.Center]: getLineRectangleIntersectionPoint(
- snappedSecondWaypoint,
- centerHandleWaypoint,
- nodeRectangle
- ),
+ // Auto-anchored end: attach to the border midpoint facing the connected
node.
+ [PositionalNodeHandleId.Center]: getBorderMidpointTowards(nodeRectangle,
snappedSecondWaypoint),
});
}
diff --git a/packages/xyflow-react-kie-diagram/src/maths/DcMaths.ts
b/packages/xyflow-react-kie-diagram/src/maths/DcMaths.ts
index c1b025e7bb7..31638a39d3e 100644
--- a/packages/xyflow-react-kie-diagram/src/maths/DcMaths.ts
+++ b/packages/xyflow-react-kie-diagram/src/maths/DcMaths.ts
@@ -21,7 +21,7 @@ import { SnapGrid } from "../snapgrid/SnapGrid";
import { snapBoundsDimensions, snapBoundsPosition } from
"../snapgrid/SnapGrid";
import { PositionalNodeHandleId } from "../nodes/PositionalNodeHandles";
import { AutoPositionedEdgeMarker } from "../edges/AutoPositionedEdgeMarker";
-import { getCenter } from "./Maths";
+import { Bounds, getCenter } from "./Maths";
import { DC__Point, DC__Bounds, DC__Dimension } from "./model";
export const DEFAULT_INTRACTION_WIDTH = 40;
@@ -58,6 +58,22 @@ export function getPointForHandle({
}
}
+/** Midpoint of `node`'s border on the side facing `towards`, by the same
dominant-axis rule as auto-positioned edges. */
+export function getBorderMidpointTowards(node: Bounds, towards: DC__Point):
DC__Point {
+ const centerX = (node.x ?? 0) + (node.width ?? 0) / 2;
+ const centerY = (node.y ?? 0) + (node.height ?? 0) / 2;
+ const dx = towards["@_x"] - centerX;
+ const dy = towards["@_y"] - centerY;
+ if (Math.abs(dx) > Math.abs(dy)) {
+ return dx >= 0
+ ? { "@_x": (node.x ?? 0) + (node.width ?? 0), "@_y": centerY }
+ : { "@_x": node.x ?? 0, "@_y": centerY };
+ }
+ return dy >= 0
+ ? { "@_x": centerX, "@_y": (node.y ?? 0) + (node.height ?? 0) }
+ : { "@_x": centerX, "@_y": node.y ?? 0 };
+}
+
export function getHandlePosition({
shapeBounds,
waypoint,
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]