This is an automated email from the ASF dual-hosted git repository.
thiagoelg 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 3d1b6f0d5ea NO-ISSUE: Fix Sub-Process handles on hover in the BPMN
Editor (#3608)
3d1b6f0d5ea is described below
commit 3d1b6f0d5eac89ce087cef262a8d7b236ebcb7f2
Author: Thiago Lugli <[email protected]>
AuthorDate: Fri May 29 23:33:20 2026 -0300
NO-ISSUE: Fix Sub-Process handles on hover in the BPMN Editor (#3608)
---
packages/bpmn-editor/src/diagram/nodes/Nodes.tsx | 27 +++++++++-
packages/bpmn-editor/src/hover/useHoverPosition.ts | 59 ++++++++++++++++++++++
2 files changed, 85 insertions(+), 1 deletion(-)
diff --git a/packages/bpmn-editor/src/diagram/nodes/Nodes.tsx
b/packages/bpmn-editor/src/diagram/nodes/Nodes.tsx
index b3a06f12cce..8d115202457 100644
--- a/packages/bpmn-editor/src/diagram/nodes/Nodes.tsx
+++ b/packages/bpmn-editor/src/diagram/nodes/Nodes.tsx
@@ -56,6 +56,7 @@ import { OutgoingStuffNodePanel } from
"@kie-tools/xyflow-react-kie-diagram/dist
import { PositionalNodeHandles } from
"@kie-tools/xyflow-react-kie-diagram/dist/nodes/PositionalNodeHandles";
import { useIsHovered } from
"@kie-tools/xyflow-react-kie-diagram/dist/reactExt/useIsHovered";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useHoverPosition } from "../../hover/useHoverPosition";
import { updateFlowElement, updateLane, updateTextAnnotation } from
"../../mutations/renameNode";
import { Normalized } from "../../normalization/normalize";
import { useBpmnEditorStore, useBpmnEditorStoreApi } from
"../../store/StoreContext";
@@ -929,12 +930,36 @@ export const SubProcessNode = React.memo(
(s) => (isHovered || isResizing) &&
s.xyFlowReactKieDiagram.draggingNodes.length === 0
);
+ // useIsHovered(ref) doesn't work here for two reasons:
+ // 1. Child nodes (e.g. Tasks inside this Sub-Process) are separate DOM
elements rendered at the
+ // same level as this node but with a higher z-index, so they sit on
top and block mouseenter.
+ // 2. InfoNodePanel and OutgoingStuffNodePanel float outside this node's
bounding box but are
+ // still DOM descendants — when they unmount because isTargeted
changed, the browser fires a
+ // fake mouseleave with the cursor at a position outside
getBoundingClientRect(), which
+ // would incorrectly clear the hover state and cause a toggle loop.
+ // Tracking the cursor position on every mousemove is immune to both
problems.
+ const isConnectionBeingMade = RF.useStore((s) => !!s.connectionNodeId &&
s.connectionNodeId !== id);
+ const [isHoveredByBounds, setIsHoveredByBounds] = useState(false);
+ const checkBounds = useCallback((x: number, y: number) => {
+ const r = ref.current?.parentElement?.getBoundingClientRect();
+ setIsHoveredByBounds(!!r && x >= r.left && x <= r.right && y >= r.top &&
y <= r.bottom);
+ }, []);
+ useHoverPosition(isConnectionBeingMade, checkBounds);
+ useEffect(() => {
+ if (!isConnectionBeingMade) {
+ setIsHoveredByBounds(false);
+ }
+ }, [isConnectionBeingMade]);
+
const { isEditingLabel, setEditingLabel, triggerEditing,
triggerEditingIfEnter } = useEditableNodeLabel(id);
useHoveredNodeAlwaysOnTop(ref, zIndex, shouldActLikeHovered, dragging,
selected, isEditingLabel);
const bpmnEditorStoreApi = useBpmnEditorStoreApi();
- const { isTargeted, isValidConnectionTarget } =
useConnectionTargetStatus(id, shouldActLikeHovered);
+ const { isTargeted, isValidConnectionTarget } = useConnectionTargetStatus(
+ id,
+ shouldActLikeHovered || isHoveredByBounds
+ );
const className = useNodeClassName(isValidConnectionTarget, id,
NODE_TYPES, EDGE_TYPES);
const nodeDimensions = useNodeDimensions({ shape, nodeType: type as
BpmnNodeType, MIN_NODE_SIZES });
diff --git a/packages/bpmn-editor/src/hover/useHoverPosition.ts
b/packages/bpmn-editor/src/hover/useHoverPosition.ts
new file mode 100644
index 00000000000..e17e28d0058
--- /dev/null
+++ b/packages/bpmn-editor/src/hover/useHoverPosition.ts
@@ -0,0 +1,59 @@
+/*
+ * 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 { useEffect } from "react";
+
+type Callback = (x: number, y: number) => void;
+
+// Module-level singleton: one DOM listener shared across all consumers.
+const subscribers = new Set<Callback>();
+let rafId: ReturnType<typeof requestAnimationFrame> | undefined;
+const latestPos = { x: 0, y: 0 };
+
+function onMouseMove(e: MouseEvent) {
+ latestPos.x = e.clientX;
+ latestPos.y = e.clientY;
+ if (rafId !== undefined) return;
+ rafId = requestAnimationFrame(() => {
+ rafId = undefined;
+ for (const fn of subscribers) {
+ fn(latestPos.x, latestPos.y);
+ }
+ });
+}
+
+export function useHoverPosition(active: boolean, callback: Callback) {
+ useEffect(() => {
+ if (!active) return;
+ if (subscribers.size === 0) {
+ document.addEventListener("mousemove", onMouseMove);
+ }
+ subscribers.add(callback);
+ return () => {
+ subscribers.delete(callback);
+ if (subscribers.size === 0) {
+ document.removeEventListener("mousemove", onMouseMove);
+ if (rafId !== undefined) {
+ cancelAnimationFrame(rafId);
+ rafId = undefined;
+ }
+ }
+ };
+ }, [active, callback]);
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]