This is an automated email from the ASF dual-hosted git repository.
Kusuma04-dev 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 5fd92bf4b26 [incubator-kie-tools#4016] [BPMN Editor] Allow moving
Sequence Flow labels by drag and drop (#4015)
5fd92bf4b26 is described below
commit 5fd92bf4b26c4596ef759787dd4fc7beda97ded9
Author: Mohammad Mustafa <[email protected]>
AuthorDate: Fri Oct 30 11:51:38 2026 +0300
[incubator-kie-tools#4016] [BPMN Editor] Allow moving Sequence Flow labels
by drag and drop (#4015)
Co-authored-by: Mohammad Mustafa
<[email protected]>
---
.../bpmn-editor/src/clipboard/offsetPastedNodes.ts | 7 +
packages/bpmn-editor/src/diagram/BpmnDiagram.tsx | 121 +++++----
packages/bpmn-editor/src/diagram/edges/Edges.css | 16 ++
packages/bpmn-editor/src/diagram/edges/Edges.tsx | 225 +++++++++++++++-
packages/bpmn-editor/src/mutations/addEdge.ts | 15 ++
.../bpmn-editor/src/mutations/addEdgeWaypoint.ts | 5 +-
.../src/mutations/deleteEdgeWaypoint.ts | 5 +-
.../src/mutations/repositionEdgeLabel.ts | 300 +++++++++++++++++++++
.../src/mutations/repositionEdgeWaypoint.ts | 15 +-
.../bpmn-editor/src/mutations/repositionNode.ts | 35 ++-
packages/bpmn-editor/src/mutations/resizeNode.ts | 18 +-
.../bpmn-editor/tests-e2e/__fixtures__/edges.ts | 46 ++++
.../tests-e2e/__fixtures__/jsonModel.ts | 14 +
.../flowElements/moveSequenceFlowLabel.spec.ts | 95 +++++++
14 files changed, 844 insertions(+), 73 deletions(-)
diff --git a/packages/bpmn-editor/src/clipboard/offsetPastedNodes.ts
b/packages/bpmn-editor/src/clipboard/offsetPastedNodes.ts
index 4ca74b72fc4..4c7a3659e95 100644
--- a/packages/bpmn-editor/src/clipboard/offsetPastedNodes.ts
+++ b/packages/bpmn-editor/src/clipboard/offsetPastedNodes.ts
@@ -44,5 +44,12 @@ export function offsetPastedNodes(
waypoint["@_x"] += offsetX;
waypoint["@_y"] += offsetY;
}
+
+ // Moved labels stay with their edge.
+ const labelBounds = edge["bpmndi:BPMNLabel"]?.["dc:Bounds"];
+ if (labelBounds) {
+ labelBounds["@_x"] += offsetX;
+ labelBounds["@_y"] += offsetY;
+ }
}
}
diff --git a/packages/bpmn-editor/src/diagram/BpmnDiagram.tsx
b/packages/bpmn-editor/src/diagram/BpmnDiagram.tsx
index 9a85e8c9da0..a9b8a960bb5 100644
--- a/packages/bpmn-editor/src/diagram/BpmnDiagram.tsx
+++ b/packages/bpmn-editor/src/diagram/BpmnDiagram.tsx
@@ -63,6 +63,8 @@ import { moveNodesOutOfLane } from
"../mutations/moveNodesOutOfLane";
import { moveNodesOutOfSubProcess } from
"../mutations/moveNodesOutOfSubProcess";
import { makeBoundaryEvent } from "../mutations/makeBoundaryEvent";
import { detachBoundaryEvent } from "../mutations/detachBoundaryEvent";
+import { getEdgeLabelBoundsAfterWaypointsChange } from
"../mutations/repositionEdgeLabel";
+import { generateUuid } from
"@kie-tools/xyflow-react-kie-diagram/dist/uuid/uuid";
import { repositionEdgeWaypoint } from "../mutations/repositionEdgeWaypoint";
import { repositionNode } from "../mutations/repositionNode";
import { resizeNode } from "../mutations/resizeNode";
@@ -199,63 +201,68 @@ export function BpmnDiagram({
const onNodeRepositioned = useCallback<
OnNodeRepositioned<State, BpmnNodeType, BpmnDiagramNodeData,
BpmnDiagramEdgeData>
- >(({ state, node, controlWaypointsByEdge, newPosition, childNodeIds }) => {
- console.log("BPMN EDITOR DIAGRAM: onNodeRepositioned");
- const { delta } = repositionNode({
- definitions: state.bpmn.model.definitions,
- controlWaypointsByEdge,
- __readonly_change: {
- type: "absolute",
- nodeType: node.type as BpmnNodeType,
- selectedEdges:
[...state.computed(state).getDiagramData().selectedEdgesById.keys()],
- shapeIndex: node.data.shapeIndex,
- sourceEdgeIndexes: state
- .computed(state)
- .getDiagramData()
- .edges.flatMap((e) => (e.source === node.id && e.data?.bpmnEdge ?
[e.data.bpmnEdgeIndex] : [])),
- targetEdgeIndexes: state
- .computed(state)
- .getDiagramData()
- .edges.flatMap((e) => (e.target === node.id && e.data?.bpmnEdge ?
[e.data.bpmnEdgeIndex] : [])),
- position: newPosition,
- },
- });
-
- const allEdgeIds = state
- .computed(state)
- .getDiagramData()
- .edges.map((e) => e.id); // Simulate all edges being selected
-
- for (const nestedId of childNodeIds) {
- const nestedNode =
state.computed(state).getDiagramData().nodesById.get(nestedId);
- if (!nestedNode) {
- throw new Error("Can't reposition nested node with id " + nestedId);
- }
-
- repositionNode({
+ >(
+ ({ state, node, controlWaypointsByEdge, newPosition, childNodeIds }) => {
+ console.log("BPMN EDITOR DIAGRAM: onNodeRepositioned");
+ const { delta } = repositionNode({
+ __readonly_labelReferenceDefinitions:
bpmnModelBeforeEditingRef.current.definitions,
definitions: state.bpmn.model.definitions,
controlWaypointsByEdge,
__readonly_change: {
- type: "offset",
- nodeType: nestedNode.type as BpmnNodeType,
- selectedEdges: allEdgeIds, // Makes sure all internal waypoints move
too.
- shapeIndex: nestedNode.data.shapeIndex,
+ type: "absolute",
+ nodeType: node.type as BpmnNodeType,
+ selectedEdges:
[...state.computed(state).getDiagramData().selectedEdgesById.keys()],
+ shapeIndex: node.data.shapeIndex,
sourceEdgeIndexes: state
.computed(state)
.getDiagramData()
- .edges.flatMap((e) => (e.source === nestedId && e.data?.bpmnEdge ?
[e.data.bpmnEdgeIndex] : [])),
+ .edges.flatMap((e) => (e.source === node.id && e.data?.bpmnEdge ?
[e.data.bpmnEdgeIndex] : [])),
targetEdgeIndexes: state
.computed(state)
.getDiagramData()
- .edges.flatMap((e) => (e.target === nestedId && e.data?.bpmnEdge ?
[e.data.bpmnEdgeIndex] : [])),
- offset: {
- deltaX: delta.x,
- deltaY: delta.y,
- },
+ .edges.flatMap((e) => (e.target === node.id && e.data?.bpmnEdge ?
[e.data.bpmnEdgeIndex] : [])),
+ position: newPosition,
},
});
- }
- }, []);
+
+ const allEdgeIds = state
+ .computed(state)
+ .getDiagramData()
+ .edges.map((e) => e.id); // Simulate all edges being selected
+
+ for (const nestedId of childNodeIds) {
+ const nestedNode =
state.computed(state).getDiagramData().nodesById.get(nestedId);
+ if (!nestedNode) {
+ throw new Error("Can't reposition nested node with id " + nestedId);
+ }
+
+ repositionNode({
+ __readonly_labelReferenceDefinitions:
bpmnModelBeforeEditingRef.current.definitions,
+ definitions: state.bpmn.model.definitions,
+ controlWaypointsByEdge,
+ __readonly_change: {
+ type: "offset",
+ nodeType: nestedNode.type as BpmnNodeType,
+ selectedEdges: allEdgeIds, // Makes sure all internal waypoints
move too.
+ shapeIndex: nestedNode.data.shapeIndex,
+ sourceEdgeIndexes: state
+ .computed(state)
+ .getDiagramData()
+ .edges.flatMap((e) => (e.source === nestedId && e.data?.bpmnEdge
? [e.data.bpmnEdgeIndex] : [])),
+ targetEdgeIndexes: state
+ .computed(state)
+ .getDiagramData()
+ .edges.flatMap((e) => (e.target === nestedId && e.data?.bpmnEdge
? [e.data.bpmnEdgeIndex] : [])),
+ offset: {
+ deltaX: delta.x,
+ deltaY: delta.y,
+ },
+ },
+ });
+ }
+ },
+ [bpmnModelBeforeEditingRef]
+ );
const onNodeDeleted = useCallback<OnNodeDeleted<State, BpmnNodeType,
BpmnDiagramNodeData, BpmnDiagramEdgeData>>(
({ state, node }) => {
@@ -362,6 +369,7 @@ export function BpmnDiagram({
({ state, node, newDimensions }) => {
console.log("BPMN EDITOR DIAGRAM: onNodeResized");
resizeNode({
+ __readonly_labelReferenceDefinitions:
bpmnModelBeforeEditingRef.current.definitions,
definitions: state.bpmn.model.definitions,
__readonly_snapGrid: state.xyFlowReactKieDiagram.snapGrid,
__readonly_change: {
@@ -383,7 +391,7 @@ export function BpmnDiagram({
},
});
},
- []
+ [bpmnModelBeforeEditingRef]
);
// edges
@@ -505,6 +513,22 @@ export function BpmnDiagram({
newBpmnEdge["di:waypoint"]![newBpmnEdge["di:waypoint"]!.length -
1],
];
}
+
+ // Keep the reconnected edge's label.
+ const deletedLabel = deletedBpmnEdge?.["bpmndi:BPMNLabel"];
+ if (deletedLabel?.["dc:Bounds"]) {
+ newBpmnEdge["bpmndi:BPMNLabel"] = {
+ ...deletedLabel,
+ "@_id": generateUuid(),
+ "dc:Bounds": getEdgeLabelBoundsAfterWaypointsChange({
+ __readonly_labelBounds: deletedLabel["dc:Bounds"],
+ __readonly_previousWaypoints: deletedWaypoints,
+ __readonly_newWaypoints: newBpmnEdge["di:waypoint"],
+ }),
+ };
+ } else {
+ delete newBpmnEdge["bpmndi:BPMNLabel"];
+ }
}
return { id: newBpmnEdge["@_bpmnElement"]! };
@@ -541,6 +565,7 @@ export function BpmnDiagram({
console.log("BPMN EDITOR DIAGRAM: onWaypointRepositioned");
bpmnEditorStoreApi.setState((s) => {
repositionEdgeWaypoint({
+ __readonly_labelReferenceDefinitions:
bpmnModelBeforeEditingRef.current.definitions,
definitions: s.bpmn.model.definitions,
__readonly_edgeIndex: edgeIndex,
__readonly_waypoint: waypoint,
@@ -548,7 +573,7 @@ export function BpmnDiagram({
});
});
},
- [bpmnEditorStoreApi]
+ [bpmnEditorStoreApi, bpmnModelBeforeEditingRef]
);
const onWaypointDeleted = useCallback<OnWaypointDeleted>(
diff --git a/packages/bpmn-editor/src/diagram/edges/Edges.css
b/packages/bpmn-editor/src/diagram/edges/Edges.css
index ee4a3321180..c2b542490ca 100644
--- a/packages/bpmn-editor/src/diagram/edges/Edges.css
+++ b/packages/bpmn-editor/src/diagram/edges/Edges.css
@@ -51,3 +51,19 @@
border-color: #006ba4;
box-shadow: #006ba4 2px 2px 6px;
}
+
+/* Centered on saved position. */
+.kie-bpmn-editor--floating-edge-label.pinned {
+ margin: 0;
+}
+
+/* Renderer disables pointer events. */
+.kie-bpmn-editor--floating-edge-label.movable {
+ pointer-events: all;
+ cursor: move;
+ touch-action: none;
+}
+
+.kie-bpmn-editor--floating-edge-label.dragging {
+ z-index: 1;
+}
diff --git a/packages/bpmn-editor/src/diagram/edges/Edges.tsx
b/packages/bpmn-editor/src/diagram/edges/Edges.tsx
index aa9c6596624..91e2b6d5662 100644
--- a/packages/bpmn-editor/src/diagram/edges/Edges.tsx
+++ b/packages/bpmn-editor/src/diagram/edges/Edges.tsx
@@ -26,16 +26,17 @@ import { DEFAULT_INTRACTION_WIDTH } from
"@kie-tools/xyflow-react-kie-diagram/di
import { propsHaveSameValuesDeep } from
"@kie-tools/xyflow-react-kie-diagram/dist/memoization/memoization";
import { useIsHovered } from
"@kie-tools/xyflow-react-kie-diagram/dist/reactExt/useIsHovered";
import * as React from "react";
-import { useCallback, useMemo, useRef } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as RF from "reactflow";
import { AssociationPath, SequenceFlowPath } from "./EdgeSvgs";
import { BpmnDiagramEdgeData, MIN_NODE_SIZES } from "../BpmnDiagramDomain";
-import { useBpmnEditorStoreApi } from "../../store/StoreContext";
+import { useBpmnEditorStore, useBpmnEditorStoreApi } from
"../../store/StoreContext";
import {
EditableNodeLabel,
useEditableNodeLabel,
} from "@kie-tools/xyflow-react-kie-diagram/dist/nodes/EditableNodeLabel";
import { updateFlowElement } from "../../mutations/renameNode";
+import { getEdgeLabelCenter, repositionEdgeLabel } from
"../../mutations/repositionEdgeLabel";
import "./Edges.css";
import { useBpmnEditorI18n } from "../../i18n";
@@ -84,12 +85,6 @@ export const SequenceFlowEdge = React.memo((props:
RF.EdgeProps<BpmnDiagramEdgeD
}
}, [waypoints]);
- const labelStyle = useMemo(() => {
- return {
- transform:
`translate(${labelPosition["@_x"]}px,${labelPosition["@_y"]}px)`,
- };
- }, [labelPosition]);
-
const bpmnEditorStoreApi = useBpmnEditorStoreApi();
const id = props.data?.bpmnElement["@_id"] ?? props.id;
@@ -109,6 +104,191 @@ export const SequenceFlowEdge = React.memo((props:
RF.EdgeProps<BpmnDiagramEdgeD
const { isEditingLabel, setEditingLabel, triggerEditing: triggerEditingLabel
} = useEditableNodeLabel(props.id);
+ // Label drag, saved on drop.
+
+ const isReadOnly = useBpmnEditorStore((s) => s.settings.isReadOnly);
+ const reactFlowStoreApi = RF.useStoreApi();
+ const labelRef = useRef<HTMLDivElement>(null);
+ const labelDragRef = useRef<EdgeLabelDrag | undefined>(undefined);
+ const [draggedLabelCenter, setDraggedLabelCenter] = useState<Point |
undefined>(undefined);
+ const isDraggingLabel = draggedLabelCenter !== undefined;
+
+ const pinnedLabelCenter = useMemo(
+ () =>
getEdgeLabelCenter(props.data?.bpmnEdge?.["bpmndi:BPMNLabel"]?.["dc:Bounds"]),
+ [props.data?.bpmnEdge]
+ );
+
+ const labelCenter = draggedLabelCenter ?? pinnedLabelCenter;
+
+ const labelStyle = useMemo(() => {
+ if (labelCenter) {
+ return { transform: `translate(${labelCenter.x}px,${labelCenter.y}px)
translate(-50%,-50%)` };
+ }
+
+ return {
+ transform:
`translate(${labelPosition["@_x"]}px,${labelPosition["@_y"]}px)`,
+ };
+ }, [labelCenter, labelPosition]);
+
+ // screenToFlowPosition snaps to grid.
+ const getFlowPosition = useCallback(
+ (clientX: number, clientY: number): Point => {
+ const { transform, domNode } = reactFlowStoreApi.getState();
+ const domNodeRect = domNode?.getBoundingClientRect();
+ return {
+ x: (clientX - (domNodeRect?.left ?? 0) - transform[0]) / transform[2],
+ y: (clientY - (domNodeRect?.top ?? 0) - transform[1]) / transform[2],
+ };
+ },
+ [reactFlowStoreApi]
+ );
+
+ const onLabelPointerDown = useCallback(
+ (e: React.PointerEvent<HTMLDivElement>) => {
+ if (isReadOnly || isEditingLabel || e.button !== 0 || !labelRef.current)
{
+ return;
+ }
+
+ // Multi-selection happens on click.
+ if (!reactFlowStoreApi.getState().multiSelectionActive) {
+ bpmnEditorStoreApi.setState((s) => {
+ const { _selectedNodes, _selectedEdges } = s.xyFlowReactKieDiagram;
+ if (_selectedNodes.length > 0 || _selectedEdges.length !== 1 ||
_selectedEdges[0] !== props.id) {
+ s.xyFlowReactKieDiagram._selectedNodes = [];
+ s.xyFlowReactKieDiagram._selectedEdges = [props.id];
+ }
+ });
+ }
+
+ const labelRect = labelRef.current.getBoundingClientRect();
+ labelDragRef.current = {
+ pointerId: e.pointerId,
+ startClientPosition: { x: e.clientX, y: e.clientY },
+ startPointerPosition: getFlowPosition(e.clientX, e.clientY),
+ startCenter: getFlowPosition(labelRect.left + labelRect.width / 2,
labelRect.top + labelRect.height / 2),
+ width: labelRef.current.offsetWidth,
+ height: labelRef.current.offsetHeight,
+ hasMoved: false,
+ };
+
+ try {
+ e.currentTarget.setPointerCapture(e.pointerId);
+ } catch (error) {
+ // Inactive pointers can't be captured.
+ }
+ },
+ [bpmnEditorStoreApi, getFlowPosition, isEditingLabel, isReadOnly,
props.id, reactFlowStoreApi]
+ );
+
+ const onLabelPointerMove = useCallback(
+ (e: React.PointerEvent<HTMLDivElement>) => {
+ const drag = labelDragRef.current;
+ if (!drag || drag.pointerId !== e.pointerId) {
+ return;
+ }
+
+ if (!drag.hasMoved) {
+ const distance = Math.hypot(e.clientX - drag.startClientPosition.x,
e.clientY - drag.startClientPosition.y);
+ if (distance < EDGE_LABEL_DRAG_THRESHOLD_IN_PX) {
+ return; // Clicks never move labels.
+ }
+ drag.hasMoved = true;
+ }
+
+ setDraggedLabelCenter(getDraggedLabelCenter(drag,
getFlowPosition(e.clientX, e.clientY)));
+ },
+ [getFlowPosition]
+ );
+
+ const edgeIndex = props.data?.bpmnEdgeIndex;
+
+ const onLabelPointerUp = useCallback(
+ (e: React.PointerEvent<HTMLDivElement>) => {
+ const drag = labelDragRef.current;
+ if (!drag || drag.pointerId !== e.pointerId) {
+ return;
+ }
+
+ labelDragRef.current = undefined;
+
+ const center = getDraggedLabelCenter(drag, getFlowPosition(e.clientX,
e.clientY));
+ const isBackWherePinned =
+ !!pinnedLabelCenter &&
+ Math.abs(pinnedLabelCenter.x - center.x) < 0.01 &&
+ Math.abs(pinnedLabelCenter.y - center.y) < 0.01;
+
+ if (
+ !drag.hasMoved ||
+ isBackWherePinned ||
+ edgeIndex === undefined ||
+ !Number.isFinite(center.x) ||
+ !Number.isFinite(center.y)
+ ) {
+ setDraggedLabelCenter(undefined);
+ return;
+ }
+
+ // Avoids flicker until model updates.
+ setDraggedLabelCenter(center);
+ try {
+ bpmnEditorStoreApi.setState((s) => {
+ repositionEdgeLabel({
+ definitions: s.bpmn.model.definitions,
+ __readonly_edgeIndex: edgeIndex,
+ __readonly_bounds: {
+ "@_x": center.x - drag.width / 2,
+ "@_y": center.y - drag.height / 2,
+ "@_width": drag.width,
+ "@_height": drag.height,
+ },
+ });
+ });
+ } catch (error) {
+ setDraggedLabelCenter(undefined);
+ throw error;
+ }
+ },
+ [bpmnEditorStoreApi, edgeIndex, getFlowPosition, pinnedLabelCenter]
+ );
+
+ // Edge changed; clear dragged position.
+ useEffect(() => {
+ if (!labelDragRef.current) {
+ setDraggedLabelCenter(undefined);
+ }
+ }, [props.data?.bpmnEdge]);
+
+ const cancelLabelDrag = useCallback(() => {
+ labelDragRef.current = undefined;
+ setDraggedLabelCenter(undefined);
+ }, []);
+
+ // Also fired after pointerup.
+ const onLabelLostPointerCapture = useCallback(() => {
+ if (labelDragRef.current) {
+ cancelLabelDrag();
+ }
+ }, [cancelLabelDrag]);
+
+ // Escape cancels the drag.
+ useEffect(() => {
+ if (!isDraggingLabel) {
+ return;
+ }
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.stopPropagation();
+ cancelLabelDrag();
+ }
+ };
+
+ window.addEventListener("keydown", onKeyDown, true);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown, true);
+ };
+ }, [cancelLabelDrag, isDraggingLabel]);
+
const onDoubleClickEdge = useCallback(
(e: React.MouseEvent) => {
if (e.metaKey) {
@@ -143,8 +323,14 @@ export const SequenceFlowEdge = React.memo((props:
RF.EdgeProps<BpmnDiagramEdgeD
(!!props.data.bpmnElement["@_name"] || isEditingLabel) && (
<RF.EdgeLabelRenderer>
<div
+ ref={labelRef}
style={labelStyle}
- className={`kie-bpmn-editor--floating-edge-label
edge-label-renderer__custom-edge nodrag nopan ${props.selected ? "selected" :
""}`}
+ className={`kie-bpmn-editor--floating-edge-label
edge-label-renderer__custom-edge nodrag nopan ${props.selected ? "selected" :
""} ${labelCenter ? "pinned" : ""} ${isReadOnly ? "" : "movable"}
${isDraggingLabel ? "dragging" : ""}`}
+ onPointerDown={isReadOnly ? undefined : onLabelPointerDown}
+ onPointerMove={isReadOnly ? undefined : onLabelPointerMove}
+ onPointerUp={isReadOnly ? undefined : onLabelPointerUp}
+ onPointerCancel={isReadOnly ? undefined : cancelLabelDrag}
+ onLostPointerCapture={isReadOnly ? undefined :
onLabelLostPointerCapture}
>
<EditableNodeLabel
id={props.id}
@@ -238,3 +424,24 @@ export const AssociationEdge = React.memo((props:
RF.EdgeProps<BpmnDiagramEdgeDa
}, propsHaveSameValuesDeep);
const NO_VALIDATION = () => true;
+
+const EDGE_LABEL_DRAG_THRESHOLD_IN_PX = 3;
+
+type Point = { x: number; y: number };
+
+type EdgeLabelDrag = {
+ pointerId: number;
+ startClientPosition: Point;
+ startPointerPosition: Point;
+ startCenter: Point;
+ width: number;
+ height: number;
+ hasMoved: boolean;
+};
+
+function getDraggedLabelCenter(drag: EdgeLabelDrag, pointerPosition: Point):
Point {
+ return {
+ x: drag.startCenter.x + pointerPosition.x - drag.startPointerPosition.x,
+ y: drag.startCenter.y + pointerPosition.y - drag.startPointerPosition.y,
+ };
+}
diff --git a/packages/bpmn-editor/src/mutations/addEdge.ts
b/packages/bpmn-editor/src/mutations/addEdge.ts
index 383af245097..015667e3c96 100644
--- a/packages/bpmn-editor/src/mutations/addEdge.ts
+++ b/packages/bpmn-editor/src/mutations/addEdge.ts
@@ -34,6 +34,7 @@ import { PositionalNodeHandleId } from
"@kie-tools/xyflow-react-kie-diagram/dist
import { AutoPositionedEdgeMarker } from
"@kie-tools/xyflow-react-kie-diagram/dist/edges/AutoPositionedEdgeMarker";
import { _checkIsValidConnection } from
"@kie-tools/xyflow-react-kie-diagram/dist/graph/isValidConnection";
import { addOrGetProcessAndDiagramElements } from
"./addOrGetProcessAndDiagramElements";
+import { getEdgeLabelBoundsAfterWaypointsChange } from "./repositionEdgeLabel";
import {
getDiscreteAutoPositioningEdgeIdMarker,
getPointForHandle,
@@ -284,6 +285,20 @@ export function addEdge({
"di:waypoint": newWaypoints,
};
+ // Keep label with kept waypoints.
+ const removedLabel = removedBpmnEdge?.["bpmndi:BPMNLabel"];
+ if (__readonly_keepWaypoints && removedLabel?.["dc:Bounds"]) {
+ newBpmnEdge["bpmndi:BPMNLabel"] = {
+ ...removedLabel,
+ "@_id": generateUuid(),
+ "dc:Bounds": getEdgeLabelBoundsAfterWaypointsChange({
+ __readonly_labelBounds: removedLabel["dc:Bounds"],
+ __readonly_previousWaypoints: removedBpmnEdge?.["di:waypoint"],
+ __readonly_newWaypoints: newWaypoints,
+ }),
+ };
+ }
+
// Replace with the new one.
diagramElements.push(newBpmnEdge);
diff --git a/packages/bpmn-editor/src/mutations/addEdgeWaypoint.ts
b/packages/bpmn-editor/src/mutations/addEdgeWaypoint.ts
index 2df53dcd430..580f3701da0 100644
--- a/packages/bpmn-editor/src/mutations/addEdgeWaypoint.ts
+++ b/packages/bpmn-editor/src/mutations/addEdgeWaypoint.ts
@@ -21,6 +21,7 @@ import { BPMN20__tDefinitions } from
"@kie-tools/bpmn-marshaller/dist/schemas/bp
import { DC__Point } from
"@kie-tools/xyflow-react-kie-diagram/dist/maths/model";
import { Normalized } from "../normalization/normalize";
import { addOrGetProcessAndDiagramElements } from
"./addOrGetProcessAndDiagramElements";
+import { updateEdgeWaypointsKeepingLabelAttached } from
"./repositionEdgeLabel";
export function addEdgeWaypoint({
definitions,
@@ -46,5 +47,7 @@ export function addEdgeWaypoint({
);
}
- diagramElement["di:waypoint"]!.splice(__readonly_beforeIndex, 0,
__readonly_waypoint);
+ updateEdgeWaypointsKeepingLabelAttached(diagramElement, () => {
+ diagramElement["di:waypoint"]!.splice(__readonly_beforeIndex, 0,
__readonly_waypoint);
+ });
}
diff --git a/packages/bpmn-editor/src/mutations/deleteEdgeWaypoint.ts
b/packages/bpmn-editor/src/mutations/deleteEdgeWaypoint.ts
index 4c8f07e4214..3fbc0f4c0a4 100644
--- a/packages/bpmn-editor/src/mutations/deleteEdgeWaypoint.ts
+++ b/packages/bpmn-editor/src/mutations/deleteEdgeWaypoint.ts
@@ -20,6 +20,7 @@
import { BPMN20__tDefinitions } from
"@kie-tools/bpmn-marshaller/dist/schemas/bpmn-2_0/ts-gen/types";
import { Normalized } from "../normalization/normalize";
import { addOrGetProcessAndDiagramElements } from
"./addOrGetProcessAndDiagramElements";
+import { updateEdgeWaypointsKeepingLabelAttached } from
"./repositionEdgeLabel";
export function deleteEdgeWaypoint({
definitions,
@@ -43,5 +44,7 @@ export function deleteEdgeWaypoint({
);
}
- diagramElement["di:waypoint"]!.splice(__readonly_waypointIndex, 1);
+ updateEdgeWaypointsKeepingLabelAttached(diagramElement, () => {
+ diagramElement["di:waypoint"]!.splice(__readonly_waypointIndex, 1);
+ });
}
diff --git a/packages/bpmn-editor/src/mutations/repositionEdgeLabel.ts
b/packages/bpmn-editor/src/mutations/repositionEdgeLabel.ts
new file mode 100644
index 00000000000..5157477e3f1
--- /dev/null
+++ b/packages/bpmn-editor/src/mutations/repositionEdgeLabel.ts
@@ -0,0 +1,300 @@
+/*
+ * 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 { BPMN20__tDefinitions } from
"@kie-tools/bpmn-marshaller/dist/schemas/bpmn-2_0/ts-gen/types";
+import { DC__Bounds, DC__Point } from
"@kie-tools/xyflow-react-kie-diagram/dist/maths/model";
+import { generateUuid } from
"@kie-tools/xyflow-react-kie-diagram/dist/uuid/uuid";
+import { Normalized } from "../normalization/normalize";
+import { addOrGetProcessAndDiagramElements } from
"./addOrGetProcessAndDiagramElements";
+
+/**
+ * Pins the label of a BPMNEdge to the given bounds. Labels without bounds are
positioned automatically.
+ */
+export function repositionEdgeLabel({
+ definitions,
+ __readonly_edgeIndex,
+ __readonly_bounds,
+}: {
+ definitions: Normalized<BPMN20__tDefinitions>;
+ __readonly_edgeIndex: number;
+ __readonly_bounds: DC__Bounds;
+}) {
+ const { diagramElements } = addOrGetProcessAndDiagramElements({ definitions
});
+
+ const diagramElement = diagramElements[__readonly_edgeIndex];
+ if (diagramElement?.__$$element !== "bpmndi:BPMNEdge") {
+ throw new Error("BPMN MUTATION: Can't reposition the label of an element
that is not a BPMNEdge.");
+ }
+
+ if (!isFiniteBounds(__readonly_bounds)) {
+ throw new Error("BPMN MUTATION: Can't reposition the label of a BPMNEdge
to invalid bounds.");
+ }
+
+ diagramElement["bpmndi:BPMNLabel"] ??= { "@_id": generateUuid() };
+ diagramElement["bpmndi:BPMNLabel"]["dc:Bounds"] = {
+ "@_x": __readonly_bounds["@_x"],
+ "@_y": __readonly_bounds["@_y"],
+ "@_width": __readonly_bounds["@_width"],
+ "@_height": __readonly_bounds["@_height"],
+ };
+}
+
+/**
+ * Runs `updateWaypoints` and then moves the edge's label (if it has explicit
bounds) so that it stays at the same
+ * place relative to the edge path, like the label was attached to it.
+ *
+ * `referenceEdge` is the edge as it was before the ongoing gesture.
Calculating from it doesn't accumulate errors during
+ * a drag. It's ignored when it doesn't match the current label anymore, as
the label or the edge changed since then.
+ */
+export function updateEdgeWaypointsKeepingLabelAttached(
+ edge: EdgeWithLabel,
+ updateWaypoints: () => void,
+ referenceEdge?: EdgeWithLabel
+) {
+ const labelBounds = edge["bpmndi:BPMNLabel"]?.["dc:Bounds"];
+ if (!labelBounds) {
+ updateWaypoints();
+ return;
+ }
+
+ const source = referenceEdge && isReferenceOf(referenceEdge, edge) ?
referenceEdge : edge;
+ const previousBounds = { ...source["bpmndi:BPMNLabel"]!["dc:Bounds"]! };
+ const previousWaypoints = copyWaypoints(source["di:waypoint"]);
+
+ updateWaypoints();
+
+ const newBounds = getEdgeLabelBoundsAfterWaypointsChange({
+ __readonly_labelBounds: previousBounds,
+ __readonly_previousWaypoints: previousWaypoints,
+ __readonly_newWaypoints: edge["di:waypoint"],
+ });
+
+ if (newBounds["@_x"] !== labelBounds["@_x"] || newBounds["@_y"] !==
labelBounds["@_y"]) {
+ labelBounds["@_x"] = newBounds["@_x"];
+ labelBounds["@_y"] = newBounds["@_y"];
+ }
+}
+
+function isReferenceOf(referenceEdge: EdgeWithLabel, edge: EdgeWithLabel) {
+ const referenceBounds = referenceEdge["bpmndi:BPMNLabel"]?.["dc:Bounds"];
+ const labelBounds = edge["bpmndi:BPMNLabel"]?.["dc:Bounds"];
+ if (
+ !referenceBounds ||
+ !labelBounds ||
+ referenceEdge["bpmndi:BPMNLabel"]?.["@_id"] !==
edge["bpmndi:BPMNLabel"]?.["@_id"] ||
+ referenceBounds["@_width"] !== labelBounds["@_width"] ||
+ referenceBounds["@_height"] !== labelBounds["@_height"]
+ ) {
+ return false;
+ }
+
+ // Reference must reproduce the label.
+ const expectedBounds = getEdgeLabelBoundsAfterWaypointsChange({
+ __readonly_labelBounds: referenceBounds,
+ __readonly_previousWaypoints: referenceEdge["di:waypoint"],
+ __readonly_newWaypoints: edge["di:waypoint"],
+ });
+
+ return (
+ Math.abs(expectedBounds["@_x"] - labelBounds["@_x"]) < REFERENCE_TOLERANCE
&&
+ Math.abs(expectedBounds["@_y"] - labelBounds["@_y"]) < REFERENCE_TOLERANCE
+ );
+}
+
+/**
+ * Calculates where a label should go after the waypoints of its edge changed.
+ *
+ * The label center is projected on the previous path. The same spot is then
found on the new path (same segment and
+ * relative position when the number of waypoints didn't change, same relative
length otherwise), and the offset between
+ * the label center and the path is rotated by how much that segment rotated.
Invalid inputs keep the label where it is.
+ */
+export function getEdgeLabelBoundsAfterWaypointsChange({
+ __readonly_labelBounds,
+ __readonly_previousWaypoints,
+ __readonly_newWaypoints,
+}: {
+ __readonly_labelBounds: DC__Bounds;
+ __readonly_previousWaypoints: DC__Point[] | undefined;
+ __readonly_newWaypoints: DC__Point[] | undefined;
+}): DC__Bounds {
+ const bounds = { ...__readonly_labelBounds };
+
+ const previousPath = toPath(__readonly_previousWaypoints);
+ const newPath = toPath(__readonly_newWaypoints);
+ const center = getEdgeLabelCenter(bounds);
+ if (!previousPath || !newPath || !center) {
+ return bounds;
+ }
+
+ const previousAnchor = getClosestPointOnPath(previousPath, center);
+ const newAnchor =
+ previousPath.length === newPath.length
+ ? getPointOnSegment(newPath, previousAnchor.segmentIndex,
previousAnchor.segmentFraction)
+ : getPointAtLength(newPath, getPathLength(newPath) *
previousAnchor.lengthFraction);
+
+ const previousAngle = getSegmentAngle(previousPath,
previousAnchor.segmentIndex);
+ const newAngle = getSegmentAngle(newPath, newAnchor.segmentIndex);
+ const rotation = previousAngle === undefined || newAngle === undefined ? 0 :
newAngle - previousAngle;
+ const offset = rotate({ x: center.x - previousAnchor.x, y: center.y -
previousAnchor.y }, rotation);
+
+ const newX = newAnchor.x + offset.x - (center.x - bounds["@_x"]);
+ const newY = newAnchor.y + offset.y - (center.y - bounds["@_y"]);
+ if (!Number.isFinite(newX) || !Number.isFinite(newY)) {
+ return bounds;
+ }
+
+ bounds["@_x"] = newX;
+ bounds["@_y"] = newY;
+ return bounds;
+}
+
+/**
+ * Center of the label bounds, or `undefined` when the label has no usable
position. Missing sizes count as zero.
+ */
+export function getEdgeLabelCenter(bounds: Partial<DC__Bounds> | undefined):
Point | undefined {
+ if (!bounds || !Number.isFinite(bounds["@_x"]) ||
!Number.isFinite(bounds["@_y"])) {
+ return undefined;
+ }
+
+ return {
+ x: bounds["@_x"]! + (Number.isFinite(bounds["@_width"]) ?
bounds["@_width"]! : 0) / 2,
+ y: bounds["@_y"]! + (Number.isFinite(bounds["@_height"]) ?
bounds["@_height"]! : 0) / 2,
+ };
+}
+
+type Point = { x: number; y: number };
+type PointOnPath = Point & { segmentIndex: number; segmentFraction: number;
lengthFraction: number };
+type EdgeWithLabel = {
+ "di:waypoint"?: DC__Point[];
+ "bpmndi:BPMNLabel"?: { "@_id"?: string; "dc:Bounds"?: DC__Bounds };
+};
+
+const REFERENCE_TOLERANCE = 0.01;
+
+function isFiniteBounds(bounds: DC__Bounds) {
+ return (
+ Number.isFinite(bounds["@_x"]) &&
+ Number.isFinite(bounds["@_y"]) &&
+ Number.isFinite(bounds["@_width"]) &&
+ Number.isFinite(bounds["@_height"])
+ );
+}
+
+function copyWaypoints(waypoints: DC__Point[] | undefined): DC__Point[] |
undefined {
+ return waypoints?.map((w) => ({ "@_x": w["@_x"], "@_y": w["@_y"] }));
+}
+
+function toPath(waypoints: DC__Point[] | undefined): Point[] | undefined {
+ if (!waypoints || waypoints.length < 2) {
+ return undefined;
+ }
+
+ const path = waypoints.map((w) => ({ x: w?.["@_x"], y: w?.["@_y"] }));
+ return path.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) ?
path : undefined;
+}
+
+function getSegmentLength(path: Point[], segmentIndex: number) {
+ return Math.hypot(path[segmentIndex + 1].x - path[segmentIndex].x,
path[segmentIndex + 1].y - path[segmentIndex].y);
+}
+
+function getPathLength(path: Point[]) {
+ let length = 0;
+ for (let i = 0; i < path.length - 1; i++) {
+ length += getSegmentLength(path, i);
+ }
+ return length;
+}
+
+function getSegmentAngle(path: Point[], segmentIndex: number) {
+ const a = path[segmentIndex];
+ const b = path[segmentIndex + 1];
+ return a.x === b.x && a.y === b.y ? undefined : Math.atan2(b.y - a.y, b.x -
a.x);
+}
+
+function getClosestPointOnPath(path: Point[], point: Point): PointOnPath {
+ const totalLength = getPathLength(path);
+
+ let closest: PointOnPath = { ...path[0], segmentIndex: 0, segmentFraction:
0, lengthFraction: 0 };
+ let closestDistance = Infinity;
+ let lengthBeforeSegment = 0;
+
+ for (let i = 0; i < path.length - 1; i++) {
+ const a = path[i];
+ const b = path[i + 1];
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const squaredLength = dx * dx + dy * dy;
+ const segmentFraction =
+ squaredLength === 0 ? 0 : clamp(((point.x - a.x) * dx + (point.y - a.y)
* dy) / squaredLength, 0, 1);
+
+ const x = a.x + segmentFraction * dx;
+ const y = a.y + segmentFraction * dy;
+ const distance = (point.x - x) * (point.x - x) + (point.y - y) * (point.y
- y);
+
+ if (distance < closestDistance) {
+ closestDistance = distance;
+ closest = {
+ x,
+ y,
+ segmentIndex: i,
+ segmentFraction,
+ lengthFraction:
+ totalLength === 0 ? 0 : (lengthBeforeSegment + segmentFraction *
Math.sqrt(squaredLength)) / totalLength,
+ };
+ }
+
+ lengthBeforeSegment += Math.sqrt(squaredLength);
+ }
+
+ return closest;
+}
+
+function getPointOnSegment(path: Point[], segmentIndex: number,
segmentFraction: number): PointOnPath {
+ const a = path[segmentIndex];
+ const b = path[segmentIndex + 1];
+ return {
+ x: a.x + segmentFraction * (b.x - a.x),
+ y: a.y + segmentFraction * (b.y - a.y),
+ segmentIndex,
+ segmentFraction,
+ lengthFraction: 0, // Not needed.
+ };
+}
+
+function getPointAtLength(path: Point[], length: number): PointOnPath {
+ let remainingLength = Math.max(0, length);
+ for (let i = 0; i < path.length - 1; i++) {
+ const segmentLength = getSegmentLength(path, i);
+ if (remainingLength <= segmentLength || i === path.length - 2) {
+ return getPointOnSegment(path, i, segmentLength === 0 ? 0 :
clamp(remainingLength / segmentLength, 0, 1));
+ }
+ remainingLength -= segmentLength;
+ }
+ return getPointOnSegment(path, 0, 0);
+}
+
+function rotate(vector: Point, angle: number): Point {
+ const cos = Math.cos(angle);
+ const sin = Math.sin(angle);
+ return { x: vector.x * cos - vector.y * sin, y: vector.x * sin + vector.y *
cos };
+}
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(max, Math.max(min, value));
+}
diff --git a/packages/bpmn-editor/src/mutations/repositionEdgeWaypoint.ts
b/packages/bpmn-editor/src/mutations/repositionEdgeWaypoint.ts
index cf4e143af06..fcaf570d393 100644
--- a/packages/bpmn-editor/src/mutations/repositionEdgeWaypoint.ts
+++ b/packages/bpmn-editor/src/mutations/repositionEdgeWaypoint.ts
@@ -20,20 +20,25 @@
import { DC__Point } from
"@kie-tools/xyflow-react-kie-diagram/dist/maths/model";
import { Normalized } from "../normalization/normalize";
import { addOrGetProcessAndDiagramElements } from
"./addOrGetProcessAndDiagramElements";
+import { updateEdgeWaypointsKeepingLabelAttached } from
"./repositionEdgeLabel";
import { BPMN20__tDefinitions } from
"@kie-tools/bpmn-marshaller/dist/schemas/bpmn-2_0/ts-gen/types";
export function repositionEdgeWaypoint({
definitions,
+ __readonly_labelReferenceDefinitions,
__readonly_edgeIndex,
__readonly_waypointIndex,
__readonly_waypoint,
}: {
definitions: Normalized<BPMN20__tDefinitions>;
+ __readonly_labelReferenceDefinitions?: BPMN20__tDefinitions;
__readonly_edgeIndex: number;
__readonly_waypointIndex: number;
__readonly_waypoint: DC__Point;
}) {
const { diagramElements } = addOrGetProcessAndDiagramElements({ definitions
});
+ const referenceElements =
+
__readonly_labelReferenceDefinitions?.["bpmndi:BPMNDiagram"]?.[0]?.["bpmndi:BPMNPlane"]["di:DiagramElement"];
const diagramElement = diagramElements[__readonly_edgeIndex];
if (diagramElement.__$$element !== "bpmndi:BPMNEdge") {
@@ -46,5 +51,13 @@ export function repositionEdgeWaypoint({
);
}
- diagramElement["di:waypoint"]![__readonly_waypointIndex] =
__readonly_waypoint;
+ updateEdgeWaypointsKeepingLabelAttached(
+ diagramElement,
+ () => {
+ diagramElement["di:waypoint"]![__readonly_waypointIndex] =
__readonly_waypoint;
+ },
+ referenceElements?.find((e) => e.__$$element === "bpmndi:BPMNEdge" &&
e["@_id"] === diagramElement["@_id"]) as
+ | typeof diagramElement
+ | undefined
+ );
}
diff --git a/packages/bpmn-editor/src/mutations/repositionNode.ts
b/packages/bpmn-editor/src/mutations/repositionNode.ts
index 8fc19e52407..d009c5c8a15 100644
--- a/packages/bpmn-editor/src/mutations/repositionNode.ts
+++ b/packages/bpmn-editor/src/mutations/repositionNode.ts
@@ -23,13 +23,16 @@ import { DC__Shape } from
"@kie-tools/xyflow-react-kie-diagram/dist/maths/model"
import { BpmnNodeType } from "../diagram/BpmnDiagramDomain";
import { Normalized } from "../normalization/normalize";
import { addOrGetProcessAndDiagramElements } from
"./addOrGetProcessAndDiagramElements";
+import { updateEdgeWaypointsKeepingLabelAttached } from
"./repositionEdgeLabel";
export function repositionNode({
definitions,
+ __readonly_labelReferenceDefinitions,
controlWaypointsByEdge,
__readonly_change,
}: {
definitions: Normalized<BPMN20__tDefinitions>;
+ __readonly_labelReferenceDefinitions?: BPMN20__tDefinitions;
/**
* This will keep track of all waypoints that were updated, in the case
where multiple nodes move together.
* This will make sure we only move edges once, even though they might be
source/target edges for multiple nodes.
@@ -53,6 +56,8 @@ export function repositionNode({
);
}) {
const { diagramElements } = addOrGetProcessAndDiagramElements({ definitions
});
+ const referenceElements =
+
__readonly_labelReferenceDefinitions?.["bpmndi:BPMNDiagram"]?.[0]?.["bpmndi:BPMNPlane"]["di:DiagramElement"];
const shape = diagramElements?.[__readonly_change.shapeIndex] as
Normalized<DC__Shape> | undefined;
const shapeBounds = shape?.["dc:Bounds"];
@@ -95,18 +100,26 @@ export function repositionNode({
});
controlWaypointsByEdge.set(edgeIndex,
controlWaypointsByEdge.get(edgeIndex) ?? new Set());
- for (const wi of waypointIndexes) {
- const waypointsControl = controlWaypointsByEdge.get(edgeIndex)!;
- if (waypointsControl.has(wi)) {
- continue;
- } else {
- waypointsControl.add(wi);
- }
+ updateEdgeWaypointsKeepingLabelAttached(
+ edge,
+ () => {
+ for (const wi of waypointIndexes) {
+ const waypointsControl = controlWaypointsByEdge.get(edgeIndex)!;
+ if (waypointsControl.has(wi)) {
+ continue;
+ } else {
+ waypointsControl.add(wi);
+ }
- const w = edge["di:waypoint"][wi];
- w["@_x"] += deltaX;
- w["@_y"] += deltaY;
- }
+ const w = edge["di:waypoint"][wi];
+ w["@_x"] += deltaX;
+ w["@_y"] += deltaY;
+ }
+ },
+ referenceElements?.find((e) => e.__$$element === "bpmndi:BPMNEdge" &&
e["@_id"] === edge["@_id"]) as
+ | typeof edge
+ | undefined
+ );
}
};
diff --git a/packages/bpmn-editor/src/mutations/resizeNode.ts
b/packages/bpmn-editor/src/mutations/resizeNode.ts
index 121d922a962..b2f01413283 100644
--- a/packages/bpmn-editor/src/mutations/resizeNode.ts
+++ b/packages/bpmn-editor/src/mutations/resizeNode.ts
@@ -29,13 +29,16 @@ import { Normalized } from "../normalization/normalize";
import { addOrGetProcessAndDiagramElements } from
"./addOrGetProcessAndDiagramElements";
import { visitFlowElementsAndArtifacts } from "./_elementVisitor";
import { repositionNode } from "./repositionNode";
+import { updateEdgeWaypointsKeepingLabelAttached } from
"./repositionEdgeLabel";
export function resizeNode({
definitions,
+ __readonly_labelReferenceDefinitions,
__readonly_snapGrid,
__readonly_change,
}: {
definitions: Normalized<BPMN20__tDefinitions>;
+ __readonly_labelReferenceDefinitions?: BPMN20__tDefinitions;
__readonly_snapGrid: SnapGrid;
__readonly_change: {
bpmnElement: BpmnNodeElement;
@@ -49,6 +52,8 @@ export function resizeNode({
const edgeIndexesAlreadyUpdated = new Set<number>();
const { process, diagramElements } = addOrGetProcessAndDiagramElements({
definitions });
+ const referenceElements =
+
__readonly_labelReferenceDefinitions?.["bpmndi:BPMNDiagram"]?.[0]?.["bpmndi:BPMNPlane"]["di:DiagramElement"];
const shape = diagramElements?.[__readonly_change.shapeIndex] as
Normalized<DC__Shape> | undefined;
const shapeBounds = shape?.["dc:Bounds"];
@@ -103,6 +108,7 @@ export function resizeNode({
}
repositionNode({
+ __readonly_labelReferenceDefinitions,
definitions,
controlWaypointsByEdge: new Map(),
__readonly_change: {
@@ -151,8 +157,16 @@ export function resizeNode({
});
const offset = offsetByPosition(getHandlePosition({ shapeBounds,
waypoint }).handlePosition);
- waypoint["@_x"] += offset.x;
- waypoint["@_y"] += offset.y;
+ updateEdgeWaypointsKeepingLabelAttached(
+ edge,
+ () => {
+ waypoint["@_x"] += offset.x;
+ waypoint["@_y"] += offset.y;
+ },
+ referenceElements?.find((e) => e.__$$element === "bpmndi:BPMNEdge" &&
e["@_id"] === edge["@_id"]) as
+ | typeof edge
+ | undefined
+ );
}
};
diff --git a/packages/bpmn-editor/tests-e2e/__fixtures__/edges.ts
b/packages/bpmn-editor/tests-e2e/__fixtures__/edges.ts
index 17b55d18f4a..961d8a92498 100644
--- a/packages/bpmn-editor/tests-e2e/__fixtures__/edges.ts
+++ b/packages/bpmn-editor/tests-e2e/__fixtures__/edges.ts
@@ -18,6 +18,7 @@
*/
import { expect, Locator, Page } from "@playwright/test";
+import { BPMNDI__BPMNEdge } from
"@kie-tools/bpmn-marshaller/dist/schemas/bpmn-2_0/ts-gen/types";
import { Diagram } from "./diagram";
import { Nodes } from "./nodes";
@@ -55,4 +56,49 @@ export class Edges {
await edge.click();
await this.diagram.get().press("Delete");
}
+
+ public getLabel(args: { name: string }): Locator {
+ return this.diagram.get().getByText(args.name, { exact: true });
+ }
+
+ public async getLabelBounds(args: { name: string }) {
+ const box = await this.getLabel(args).boundingBox();
+ expect(box).not.toBeNull();
+ return box!;
+ }
+
+ public async getLabelCenterPosition(args: { name: string }): Promise<{ x:
number; y: number }> {
+ const box = await this.getLabelBounds(args);
+ return {
+ x: box.x + box.width / 2,
+ y: box.y + box.height / 2,
+ };
+ }
+
+ public async moveLabel(args: { name: string; offset: { x: number; y: number
} }) {
+ const { x, y } = await this.getLabelCenterPosition({ name: args.name });
+ await this.page.mouse.move(x, y);
+ await this.page.mouse.down();
+ await this.page.mouse.move(x + args.offset.x, y + args.offset.y, { steps:
10 });
+ await this.page.mouse.up();
+ }
+
+ public getLabelDistanceToEdge(bpmnEdge: BPMNDI__BPMNEdge) {
+ const bounds = bpmnEdge["bpmndi:BPMNLabel"]!["dc:Bounds"]!;
+ const x = bounds["@_x"] + bounds["@_width"] / 2;
+ const y = bounds["@_y"] + bounds["@_height"] / 2;
+ const waypoints = bpmnEdge["di:waypoint"];
+
+ let distance = Infinity;
+ for (let i = 0; i < waypoints.length - 1; i++) {
+ const a = waypoints[i];
+ const dx = waypoints[i + 1]["@_x"] - a["@_x"];
+ const dy = waypoints[i + 1]["@_y"] - a["@_y"];
+ const squaredLength = dx * dx + dy * dy;
+ const t =
+ squaredLength === 0 ? 0 : Math.min(1, Math.max(0, ((x - a["@_x"]) * dx
+ (y - a["@_y"]) * dy) / squaredLength));
+ distance = Math.min(distance, Math.hypot(x - a["@_x"] - t * dx, y -
a["@_y"] - t * dy));
+ }
+ return distance;
+ }
}
diff --git a/packages/bpmn-editor/tests-e2e/__fixtures__/jsonModel.ts
b/packages/bpmn-editor/tests-e2e/__fixtures__/jsonModel.ts
index bd22c748c92..9a468e10bb3 100644
--- a/packages/bpmn-editor/tests-e2e/__fixtures__/jsonModel.ts
+++ b/packages/bpmn-editor/tests-e2e/__fixtures__/jsonModel.ts
@@ -248,4 +248,18 @@ export class JsonModel {
}
return undefined;
}
+
+ public async getEdge(args: { diagramIndex?: number; bpmnElementId: string })
{
+ const edge = (await this.getPlane(args.diagramIndex ??
0))?.["di:DiagramElement"]?.find(
+ (e) => e.__$$element === "bpmndi:BPMNEdge" && e["@_bpmnElement"] ===
args.bpmnElementId
+ );
+ if (edge?.__$$element === "bpmndi:BPMNEdge") {
+ return edge;
+ }
+ return undefined;
+ }
+
+ public async getEdgeLabelBounds(args: { diagramIndex?: number;
bpmnElementId: string }) {
+ return (await this.getEdge(args))?.["bpmndi:BPMNLabel"]?.["dc:Bounds"];
+ }
}
diff --git
a/packages/bpmn-editor/tests-e2e/flowElements/moveSequenceFlowLabel.spec.ts
b/packages/bpmn-editor/tests-e2e/flowElements/moveSequenceFlowLabel.spec.ts
new file mode 100644
index 00000000000..9843136e152
--- /dev/null
+++ b/packages/bpmn-editor/tests-e2e/flowElements/moveSequenceFlowLabel.spec.ts
@@ -0,0 +1,95 @@
+/*
+ * 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 { NodeType } from "../__fixtures__/nodes";
+
+test.beforeEach(async ({ editor }) => {
+ await editor.open();
+ await editor.setInitialProcessId();
+});
+
+test.describe("Move Sequence Flow label", () => {
+ test.beforeEach(async ({ palette, nodes, edges, diagram,
sequenceFlowPropertiesPanel }) => {
+ await palette.dragNewNode({ type: NodeType.TASK, targetPosition: { x: 100,
y: 100 }, thenRenameTo: "Task A" });
+ await diagram.resetFocus();
+ await palette.dragNewNode({ type: NodeType.TASK, targetPosition: { x: 400,
y: 400 }, thenRenameTo: "Task B" });
+ await diagram.resetFocus();
+
+ await nodes.createSequenceFlow({ from: "Task A", to: "Task B" });
+
+ const edge = await edges.get({ from: "Task A", to: "Task B" });
+ await edge.click({ force: true });
+ await sequenceFlowPropertiesPanel.open();
+ await sequenceFlowPropertiesPanel.nameProperties.setName({ newName:
"Approved" });
+ await sequenceFlowPropertiesPanel.close();
+ await diagram.resetFocus();
+
+ await expect(edges.getLabel({ name: "Approved" })).toBeVisible();
+ });
+
+ test("should move the label and save its position", async ({ edges,
jsonModel }) => {
+ const bpmnElementId = (await jsonModel.getSequenceFlows())[0]["@_id"]!;
+ expect(await jsonModel.getEdgeLabelBounds({ bpmnElementId
})).toBeUndefined();
+
+ const labelCenterBeforeMove = await edges.getLabelCenterPosition({ name:
"Approved" });
+ await edges.moveLabel({ name: "Approved", offset: { x: 60, y: 40 } });
+
+ await expect.poll(() => jsonModel.getEdgeLabelBounds({ bpmnElementId
})).toBeDefined();
+ const labelCenterAfterMove = await edges.getLabelCenterPosition({ name:
"Approved" });
+ expect(labelCenterAfterMove.x - labelCenterBeforeMove.x).toBeCloseTo(60,
0);
+ expect(labelCenterAfterMove.y - labelCenterBeforeMove.y).toBeCloseTo(40,
0);
+
+ const boundsBeforeSecondMove = (await jsonModel.getEdgeLabelBounds({
bpmnElementId }))!;
+ await edges.moveLabel({ name: "Approved", offset: { x: -30, y: 20 } });
+
+ await expect
+ .poll(async () => (await jsonModel.getEdgeLabelBounds({ bpmnElementId
}))?.["@_x"])
+ .toBeCloseTo(boundsBeforeSecondMove["@_x"] - 30, 0);
+ expect((await jsonModel.getEdgeLabelBounds({ bpmnElementId
}))?.["@_y"]).toBeCloseTo(
+ boundsBeforeSecondMove["@_y"] + 20,
+ 0
+ );
+ });
+
+ test("should keep the moved label next to its Sequence Flow when a connected
node moves", async ({
+ edges,
+ nodes,
+ jsonModel,
+ }) => {
+ const bpmnElementId = (await jsonModel.getSequenceFlows())[0]["@_id"]!;
+ await edges.moveLabel({ name: "Approved", offset: { x: 40, y: 40 } });
+ await expect.poll(() => jsonModel.getEdgeLabelBounds({ bpmnElementId
})).toBeDefined();
+ const edgeBeforeNodeMove = (await jsonModel.getEdge({ bpmnElementId }))!;
+
+ const taskBCenter = await nodes.getNodeCenterPosition({ name: "Task B" });
+ await nodes.dragNodeToPosition({ name: "Task B", toPosition: { x:
taskBCenter.x + 200, y: taskBCenter.y } });
+
+ await expect
+ .poll(async () => (await jsonModel.getEdge({ bpmnElementId
}))?.["di:waypoint"])
+ .not.toEqual(edgeBeforeNodeMove["di:waypoint"]);
+ const edgeAfterNodeMove = (await jsonModel.getEdge({ bpmnElementId }))!;
+
+
expect(edgeAfterNodeMove["bpmndi:BPMNLabel"]).not.toEqual(edgeBeforeNodeMove["bpmndi:BPMNLabel"]);
+ expect(edges.getLabelDistanceToEdge(edgeAfterNodeMove)).toBeCloseTo(
+ edges.getLabelDistanceToEdge(edgeBeforeNodeMove),
+ 0
+ );
+ });
+});
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]