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]

Reply via email to