@nocobase/flow-engine 2.2.0-beta.6 → 2.2.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -57,6 +57,10 @@ const EMPTY_COLUMN_UID = "EMPTY_COLUMN";
57
57
  const TOOLBAR_DRAG_ACTIVITY_EVENT = "nb-toolbar-drag-activity";
58
58
  const TOOLBAR_DRAG_ANCHOR_EVENT = "nb-toolbar-drag-anchor";
59
59
  const MENU_SUBMENU_POPUP_SELECTOR = ".ant-menu-submenu-popup";
60
+ const getToolbarModelUidFromNode = /* @__PURE__ */ __name((node) => {
61
+ var _a;
62
+ return ((_a = node == null ? void 0 : node.closest(".nb-toolbar-container[data-model-uid]")) == null ? void 0 : _a.getAttribute("data-model-uid")) || null;
63
+ }, "getToolbarModelUidFromNode");
60
64
  const resolveOverlayAnchorTransform = /* @__PURE__ */ __name(({
61
65
  activeId,
62
66
  active,
@@ -76,7 +80,7 @@ const resolveOverlayAnchorTransform = /* @__PURE__ */ __name(({
76
80
  const resolveDraggableHostNode = /* @__PURE__ */ __name((activatorNode) => {
77
81
  const ownerDocument = activatorNode == null ? void 0 : activatorNode.ownerDocument;
78
82
  const floatToolbarContainer = activatorNode == null ? void 0 : activatorNode.closest(".nb-toolbar-container[data-model-uid]");
79
- const toolbarModelUid = floatToolbarContainer == null ? void 0 : floatToolbarContainer.getAttribute("data-model-uid");
83
+ const toolbarModelUid = getToolbarModelUidFromNode(activatorNode);
80
84
  if (!ownerDocument || !toolbarModelUid) {
81
85
  return activatorNode;
82
86
  }
@@ -99,6 +103,7 @@ const DragHandler = /* @__PURE__ */ __name(({
99
103
  const dragHandlerRef = (0, import_react.useRef)(null);
100
104
  const draggableNodeRef = (0, import_react.useRef)(null);
101
105
  const pointerPressCleanupRef = (0, import_react.useRef)(null);
106
+ const toolbarDragModelUidRef = (0, import_react.useRef)(null);
102
107
  const isDraggingRef = (0, import_react.useRef)(isDragging);
103
108
  const isPointerPressActiveRef = (0, import_react.useRef)(false);
104
109
  const isToolbarDragActiveRef = (0, import_react.useRef)(false);
@@ -128,9 +133,11 @@ const DragHandler = /* @__PURE__ */ __name(({
128
133
  if (!ownerDocument) {
129
134
  return;
130
135
  }
136
+ const toolbarModelUid = getToolbarModelUidFromNode(dragHandlerRef.current) || toolbarDragModelUidRef.current || model.uid;
137
+ toolbarDragModelUidRef.current = active ? toolbarModelUid : null;
131
138
  ownerDocument.dispatchEvent(
132
139
  new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
133
- detail: { active, modelUid: model.uid }
140
+ detail: { active, modelUid: toolbarModelUid }
134
141
  })
135
142
  );
136
143
  },
@@ -44,6 +44,7 @@ var import_react = __toESM(require("react"));
44
44
  var import_react_dom = require("react-dom");
45
45
  var import_antd = require("antd");
46
46
  var import_css = require("@emotion/css");
47
+ var import_ahooks = require("ahooks");
47
48
  var import_hooks = require("../../../../hooks");
48
49
  var import_provider = require("../../../../provider");
49
50
  var import_utils = require("../../../../utils");
@@ -312,6 +313,38 @@ const buildToolbarContainerStyle = /* @__PURE__ */ __name((portalRect, toolbarSt
312
313
  const isModelByIdProps = /* @__PURE__ */ __name((props) => {
313
314
  return "uid" in props && "modelClassName" in props && Boolean(props.uid) && Boolean(props.modelClassName);
314
315
  }, "isModelByIdProps");
316
+ const stopResizeInteractionEvent = /* @__PURE__ */ __name((event) => {
317
+ var _a;
318
+ if (!event) {
319
+ return;
320
+ }
321
+ event.preventDefault();
322
+ event.stopPropagation();
323
+ const nativeEvent = "nativeEvent" in event ? event.nativeEvent : event;
324
+ (_a = nativeEvent.stopImmediatePropagation) == null ? void 0 : _a.call(nativeEvent);
325
+ }, "stopResizeInteractionEvent");
326
+ const pendingResizeClickSuppressions = /* @__PURE__ */ new WeakMap();
327
+ const clearPendingResizeClickSuppression = /* @__PURE__ */ __name((ownerDocument) => {
328
+ const pending = pendingResizeClickSuppressions.get(ownerDocument);
329
+ if (!pending) {
330
+ return;
331
+ }
332
+ ownerDocument.removeEventListener("click", pending.listener, true);
333
+ (ownerDocument.defaultView || window).clearTimeout(pending.timer);
334
+ pendingResizeClickSuppressions.delete(ownerDocument);
335
+ }, "clearPendingResizeClickSuppression");
336
+ const suppressNextResizeClick = /* @__PURE__ */ __name((ownerDocument) => {
337
+ clearPendingResizeClickSuppression(ownerDocument);
338
+ const listener = /* @__PURE__ */ __name((event) => {
339
+ stopResizeInteractionEvent(event);
340
+ clearPendingResizeClickSuppression(ownerDocument);
341
+ }, "listener");
342
+ const timer = (ownerDocument.defaultView || window).setTimeout(() => {
343
+ clearPendingResizeClickSuppression(ownerDocument);
344
+ }, 0);
345
+ pendingResizeClickSuppressions.set(ownerDocument, { listener, timer });
346
+ ownerDocument.addEventListener("click", listener, true);
347
+ }, "suppressNextResizeClick");
315
348
  const FlowsFloatContextMenu = (0, import_reactive.observer)((props) => {
316
349
  const ctx = (0, import__.useFlowContext)();
317
350
  if (!ctx.flowSettingsEnabled) {
@@ -326,44 +359,64 @@ const ResizeHandles = /* @__PURE__ */ __name((props) => {
326
359
  const isDraggingRef = (0, import_react.useRef)(false);
327
360
  const dragTypeRef = (0, import_react.useRef)(null);
328
361
  const dragStartPosRef = (0, import_react.useRef)({ x: 0, y: 0 });
362
+ const dragOwnerDocumentRef = (0, import_react.useRef)(null);
329
363
  const { onDragStart, onDragEnd } = props;
330
- const handleDragMove = (0, import_react.useCallback)(
331
- (e) => {
332
- if (!isDraggingRef.current || !dragTypeRef.current) return;
333
- const deltaX = e.clientX - dragStartPosRef.current.x;
334
- switch (dragTypeRef.current) {
335
- case "left":
336
- props.model.parent.emitter.emit("onResizeLeft", { resizeDistance: -deltaX, model: props.model });
337
- break;
338
- case "right":
339
- props.model.parent.emitter.emit("onResizeRight", { resizeDistance: deltaX, model: props.model });
340
- break;
341
- }
342
- },
343
- [props.model]
344
- );
345
- const handleDragEnd = (0, import_react.useCallback)(() => {
364
+ const handleDragMove = (0, import_ahooks.useMemoizedFn)((e) => {
365
+ if (!isDraggingRef.current || !dragTypeRef.current) return;
366
+ stopResizeInteractionEvent(e);
367
+ const deltaX = e.clientX - dragStartPosRef.current.x;
368
+ switch (dragTypeRef.current) {
369
+ case "left":
370
+ props.model.parent.emitter.emit("onResizeLeft", { resizeDistance: -deltaX, model: props.model });
371
+ break;
372
+ case "right":
373
+ props.model.parent.emitter.emit("onResizeRight", { resizeDistance: deltaX, model: props.model });
374
+ break;
375
+ }
376
+ });
377
+ const handleDragEnd = (0, import_ahooks.useMemoizedFn)((e) => {
378
+ if (!isDraggingRef.current) {
379
+ return;
380
+ }
381
+ stopResizeInteractionEvent(e);
382
+ const ownerDocument = dragOwnerDocumentRef.current || document;
383
+ ownerDocument.removeEventListener("mousemove", handleDragMove, true);
384
+ ownerDocument.removeEventListener("mouseup", handleDragEnd, true);
385
+ suppressNextResizeClick(ownerDocument);
346
386
  isDraggingRef.current = false;
347
387
  dragTypeRef.current = null;
348
388
  dragStartPosRef.current = { x: 0, y: 0 };
349
- document.removeEventListener("mousemove", handleDragMove);
350
- document.removeEventListener("mouseup", handleDragEnd);
389
+ dragOwnerDocumentRef.current = null;
351
390
  props.model.parent.emitter.emit("onResizeEnd");
352
391
  onDragEnd == null ? void 0 : onDragEnd();
353
- }, [handleDragMove, onDragEnd, props.model]);
354
- const handleDragStart = (0, import_react.useCallback)(
355
- (e, type) => {
356
- e.preventDefault();
357
- e.stopPropagation();
358
- isDraggingRef.current = true;
359
- dragTypeRef.current = type;
360
- dragStartPosRef.current = { x: e.clientX, y: e.clientY };
361
- document.addEventListener("mousemove", handleDragMove);
362
- document.addEventListener("mouseup", handleDragEnd);
363
- onDragStart == null ? void 0 : onDragStart();
364
- },
365
- [handleDragMove, handleDragEnd, onDragStart]
366
- );
392
+ });
393
+ (0, import_react.useEffect)(() => {
394
+ return () => {
395
+ const dragOwnerDocument = dragOwnerDocumentRef.current;
396
+ dragOwnerDocument == null ? void 0 : dragOwnerDocument.removeEventListener("mousemove", handleDragMove, true);
397
+ dragOwnerDocument == null ? void 0 : dragOwnerDocument.removeEventListener("mouseup", handleDragEnd, true);
398
+ if (isDraggingRef.current) {
399
+ suppressNextResizeClick(dragOwnerDocument || document);
400
+ props.model.parent.emitter.emit("onResizeEnd");
401
+ onDragEnd == null ? void 0 : onDragEnd();
402
+ }
403
+ isDraggingRef.current = false;
404
+ dragTypeRef.current = null;
405
+ dragStartPosRef.current = { x: 0, y: 0 };
406
+ dragOwnerDocumentRef.current = null;
407
+ };
408
+ }, [handleDragMove, handleDragEnd, onDragEnd, props.model]);
409
+ const handleDragStart = (0, import_ahooks.useMemoizedFn)((e, type) => {
410
+ stopResizeInteractionEvent(e);
411
+ const ownerDocument = e.currentTarget.ownerDocument;
412
+ isDraggingRef.current = true;
413
+ dragTypeRef.current = type;
414
+ dragStartPosRef.current = { x: e.clientX, y: e.clientY };
415
+ dragOwnerDocumentRef.current = ownerDocument;
416
+ ownerDocument.addEventListener("mousemove", handleDragMove, true);
417
+ ownerDocument.addEventListener("mouseup", handleDragEnd, true);
418
+ onDragStart == null ? void 0 : onDragStart();
419
+ });
367
420
  return /* @__PURE__ */ import_react.default.createElement(import_react.default.Fragment, null, /* @__PURE__ */ import_react.default.createElement(
368
421
  "div",
369
422
  {
@@ -466,6 +519,7 @@ const FlowsFloatContextMenuWithModel = (0, import_reactive.observer)(
466
519
  getPopupContainer,
467
520
  handleSettingsMenuOpenChange,
468
521
  model,
522
+ modelUid,
469
523
  settingsMenuLevel,
470
524
  showCopyUidButton,
471
525
  showDeleteButton,
@@ -44,6 +44,17 @@ const getToolbarModelUidFromTarget = /* @__PURE__ */ __name((target) => {
44
44
  }
45
45
  return ((_a = target.closest(".nb-toolbar-container[data-model-uid]")) == null ? void 0 : _a.getAttribute("data-model-uid")) || null;
46
46
  }, "getToolbarModelUidFromTarget");
47
+ const escapeCssAttributeValue = /* @__PURE__ */ __name((value) => {
48
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\a ").replace(/\r/g, "\\d ").replace(/\f/g, "\\c ");
49
+ }, "escapeCssAttributeValue");
50
+ const isToolbarModelUidRendered = /* @__PURE__ */ __name((ownerDocument, modelUid) => {
51
+ if (!ownerDocument) {
52
+ return false;
53
+ }
54
+ return !!ownerDocument.querySelector(
55
+ `.nb-toolbar-container[data-model-uid="${escapeCssAttributeValue(modelUid)}"]`
56
+ );
57
+ }, "isToolbarModelUidRendered");
47
58
  const isNodeWithinDescendantFloatToolbar = /* @__PURE__ */ __name((target, container, currentModelUid) => {
48
59
  const targetModelUid = getToolbarModelUidFromTarget(target);
49
60
  if (!container || !targetModelUid || targetModelUid === currentModelUid) {
@@ -222,6 +233,15 @@ const useFloatToolbarVisibility = /* @__PURE__ */ __name(({
222
233
  const isCurrentHostTarget = !childWithMenu || childWithMenu === containerRef.current;
223
234
  if (isCurrentHostTarget) {
224
235
  clearHideToolbarTimer();
236
+ setActiveChildToolbarIds((prevIds) => {
237
+ var _a;
238
+ if (!prevIds.length) {
239
+ return prevIds;
240
+ }
241
+ const ownerDocument = ((_a = containerRef.current) == null ? void 0 : _a.ownerDocument) ?? null;
242
+ const nextIds = prevIds.filter((id) => isToolbarModelUidRendered(ownerDocument, id));
243
+ return nextIds.length === prevIds.length ? prevIds : nextIds;
244
+ });
225
245
  setHostHovered(true);
226
246
  }
227
247
  setHideMenu(!!childWithMenu && childWithMenu !== containerRef.current);
@@ -2555,8 +2555,16 @@ const _FlowEngineContext = class _FlowEngineContext extends BaseFlowEngineContex
2555
2555
  });
2556
2556
  this.defineProperty("role", {
2557
2557
  get: /* @__PURE__ */ __name(() => {
2558
- var _a, _b;
2559
- return (_b = (_a = this.api) == null ? void 0 : _a.auth) == null ? void 0 : _b.role;
2558
+ var _a, _b, _c;
2559
+ const currentRole = (_b = (_a = this.api) == null ? void 0 : _a.auth) == null ? void 0 : _b.role;
2560
+ if (currentRole !== "__union__") {
2561
+ return currentRole;
2562
+ }
2563
+ const roles = (_c = this.user) == null ? void 0 : _c.roles;
2564
+ if (!Array.isArray(roles)) {
2565
+ return [];
2566
+ }
2567
+ return roles.map((role) => role == null ? void 0 : role.name).filter((name) => !!name);
2560
2568
  }, "get"),
2561
2569
  cache: false,
2562
2570
  // 注意:使用惰性 meta 工厂,避免在 i18n 尚未注入时提前求值导致无法翻译
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.2.0-beta.6",
3
+ "version": "2.2.0-beta.7",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.2.0-beta.6",
12
- "@nocobase/shared": "2.2.0-beta.6",
11
+ "@nocobase/sdk": "2.2.0-beta.7",
12
+ "@nocobase/shared": "2.2.0-beta.7",
13
13
  "ahooks": "^3.7.2",
14
14
  "axios": "^1.7.0",
15
15
  "dayjs": "^1.11.9",
@@ -37,5 +37,5 @@
37
37
  ],
38
38
  "author": "NocoBase Team",
39
39
  "license": "Apache-2.0",
40
- "gitHead": "dc9246516f8a3efd6056f22fe7b3bc947bd4575b"
40
+ "gitHead": "e6a3fa8963a73cd9ddfc1273d71b0012483e1ad8"
41
41
  }
@@ -177,6 +177,49 @@ describe('FlowContext properties and methods', () => {
177
177
  });
178
178
  });
179
179
 
180
+ it('should expose current role as a top-level variable', async () => {
181
+ const engine = new FlowEngine();
182
+ const ctx = engine.context;
183
+ ctx.defineProperty('api', { value: { auth: { role: 'admin' } } });
184
+
185
+ expect(ctx.role).toBe('admin');
186
+ await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toBe('admin');
187
+
188
+ const roleNode = ctx.getPropertyMetaTree().find((node) => node.name === 'role');
189
+ expect(roleNode).toMatchObject({
190
+ name: 'role',
191
+ title: '{{t("Current role")}}',
192
+ paths: ['role'],
193
+ });
194
+ });
195
+
196
+ it('should expose actual role names for union role mode', async () => {
197
+ const engine = new FlowEngine();
198
+ const ctx = engine.context;
199
+ ctx.defineProperty('api', { value: { auth: { role: '__union__' } } });
200
+ ctx.defineProperty('user', {
201
+ value: {
202
+ roles: [
203
+ { name: 'admin', title: 'Admin' },
204
+ { name: 'member', title: 'Member' },
205
+ ],
206
+ },
207
+ });
208
+
209
+ expect(ctx.role).toEqual(['admin', 'member']);
210
+ await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toEqual(['admin', 'member']);
211
+ });
212
+
213
+ it('should expose an empty role list for union role mode without user roles', async () => {
214
+ const engine = new FlowEngine();
215
+ const ctx = engine.context;
216
+ ctx.defineProperty('api', { value: { auth: { role: '__union__' } } });
217
+ ctx.defineProperty('user', { value: {} });
218
+
219
+ expect(ctx.role).toEqual([]);
220
+ await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toEqual([]);
221
+ });
222
+
180
223
  it('should throw sync error in get', () => {
181
224
  const ctx = new FlowContext();
182
225
  ctx.defineProperty('error', {
@@ -35,6 +35,10 @@ type ToolbarDragAnchorDetail = {
35
35
  point: ToolbarDragAnchorPoint | null;
36
36
  };
37
37
 
38
+ const getToolbarModelUidFromNode = (node: HTMLElement | null): string | null => {
39
+ return node?.closest<HTMLElement>('.nb-toolbar-container[data-model-uid]')?.getAttribute('data-model-uid') || null;
40
+ };
41
+
38
42
  export const resolveOverlayAnchorTransform = ({
39
43
  activeId,
40
44
  active,
@@ -62,7 +66,7 @@ export const resolveOverlayAnchorTransform = ({
62
66
  const resolveDraggableHostNode = (activatorNode: HTMLElement | null) => {
63
67
  const ownerDocument = activatorNode?.ownerDocument;
64
68
  const floatToolbarContainer = activatorNode?.closest<HTMLElement>('.nb-toolbar-container[data-model-uid]');
65
- const toolbarModelUid = floatToolbarContainer?.getAttribute('data-model-uid');
69
+ const toolbarModelUid = getToolbarModelUidFromNode(activatorNode);
66
70
 
67
71
  if (!ownerDocument || !toolbarModelUid) {
68
72
  return activatorNode;
@@ -95,6 +99,7 @@ export const DragHandler: FC<{ model: FlowModel; children?: React.ReactNode }> =
95
99
  const dragHandlerRef = useRef<HTMLSpanElement | null>(null);
96
100
  const draggableNodeRef = useRef<HTMLElement | null>(null);
97
101
  const pointerPressCleanupRef = useRef<(() => void) | null>(null);
102
+ const toolbarDragModelUidRef = useRef<string | null>(null);
98
103
  const isDraggingRef = useRef(isDragging);
99
104
  const isPointerPressActiveRef = useRef(false);
100
105
  const isToolbarDragActiveRef = useRef(false);
@@ -126,9 +131,13 @@ export const DragHandler: FC<{ model: FlowModel; children?: React.ReactNode }> =
126
131
  return;
127
132
  }
128
133
 
134
+ const toolbarModelUid =
135
+ getToolbarModelUidFromNode(dragHandlerRef.current) || toolbarDragModelUidRef.current || model.uid;
136
+ toolbarDragModelUidRef.current = active ? toolbarModelUid : null;
137
+
129
138
  ownerDocument.dispatchEvent(
130
139
  new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
131
- detail: { active, modelUid: model.uid },
140
+ detail: { active, modelUid: toolbarModelUid },
132
141
  }),
133
142
  );
134
143
  },
@@ -11,6 +11,7 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
11
11
  import { createPortal } from 'react-dom';
12
12
  import { Alert, Space } from 'antd';
13
13
  import { css } from '@emotion/css';
14
+ import { useMemoizedFn } from 'ahooks';
14
15
  import { FlowModel } from '../../../../models';
15
16
  import { ToolbarItemConfig } from '../../../../types';
16
17
  import { useFlowModelById } from '../../../../hooks';
@@ -394,6 +395,49 @@ const isModelByIdProps = (props: FlowsFloatContextMenuProps): props is ModelById
394
395
  return 'uid' in props && 'modelClassName' in props && Boolean(props.uid) && Boolean(props.modelClassName);
395
396
  };
396
397
 
398
+ const stopResizeInteractionEvent = (event?: MouseEvent | React.MouseEvent) => {
399
+ if (!event) {
400
+ return;
401
+ }
402
+
403
+ event.preventDefault();
404
+ event.stopPropagation();
405
+
406
+ const nativeEvent = 'nativeEvent' in event ? event.nativeEvent : event;
407
+ nativeEvent.stopImmediatePropagation?.();
408
+ };
409
+
410
+ const pendingResizeClickSuppressions = new WeakMap<
411
+ Document,
412
+ { listener: (event: MouseEvent) => void; timer: number }
413
+ >();
414
+
415
+ const clearPendingResizeClickSuppression = (ownerDocument: Document) => {
416
+ const pending = pendingResizeClickSuppressions.get(ownerDocument);
417
+ if (!pending) {
418
+ return;
419
+ }
420
+
421
+ ownerDocument.removeEventListener('click', pending.listener, true);
422
+ (ownerDocument.defaultView || window).clearTimeout(pending.timer);
423
+ pendingResizeClickSuppressions.delete(ownerDocument);
424
+ };
425
+
426
+ const suppressNextResizeClick = (ownerDocument: Document) => {
427
+ clearPendingResizeClickSuppression(ownerDocument);
428
+
429
+ const listener = (event: MouseEvent) => {
430
+ stopResizeInteractionEvent(event);
431
+ clearPendingResizeClickSuppression(ownerDocument);
432
+ };
433
+ const timer = (ownerDocument.defaultView || window).setTimeout(() => {
434
+ clearPendingResizeClickSuppression(ownerDocument);
435
+ }, 0);
436
+
437
+ pendingResizeClickSuppressions.set(ownerDocument, { listener, timer });
438
+ ownerDocument.addEventListener('click', listener, true);
439
+ };
440
+
397
441
  /**
398
442
  * FlowsFloatContextMenu组件 - 悬浮配置工具栏组件
399
443
  *
@@ -442,55 +486,80 @@ const ResizeHandles: React.FC<{
442
486
  const isDraggingRef = useRef<boolean>(false);
443
487
  const dragTypeRef = useRef<'left' | 'right' | null>(null);
444
488
  const dragStartPosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
489
+ const dragOwnerDocumentRef = useRef<Document | null>(null);
445
490
  const { onDragStart, onDragEnd } = props;
446
491
 
447
492
  // 把拖拽位移转成上层已约定的 resize 事件。
448
- const handleDragMove = useCallback(
449
- (e: MouseEvent) => {
450
- if (!isDraggingRef.current || !dragTypeRef.current) return;
451
-
452
- const deltaX = e.clientX - dragStartPosRef.current.x;
453
-
454
- switch (dragTypeRef.current) {
455
- case 'left':
456
- props.model.parent.emitter.emit('onResizeLeft', { resizeDistance: -deltaX, model: props.model });
457
- break;
458
- case 'right':
459
- props.model.parent.emitter.emit('onResizeRight', { resizeDistance: deltaX, model: props.model });
460
- break;
461
- }
462
- },
463
- [props.model],
464
- );
493
+ const handleDragMove = useMemoizedFn((e: MouseEvent) => {
494
+ if (!isDraggingRef.current || !dragTypeRef.current) return;
495
+
496
+ stopResizeInteractionEvent(e);
497
+ const deltaX = e.clientX - dragStartPosRef.current.x;
498
+
499
+ switch (dragTypeRef.current) {
500
+ case 'left':
501
+ props.model.parent.emitter.emit('onResizeLeft', { resizeDistance: -deltaX, model: props.model });
502
+ break;
503
+ case 'right':
504
+ props.model.parent.emitter.emit('onResizeRight', { resizeDistance: deltaX, model: props.model });
505
+ break;
506
+ }
507
+ });
508
+
509
+ const handleDragEnd = useMemoizedFn((e?: MouseEvent) => {
510
+ if (!isDraggingRef.current) {
511
+ return;
512
+ }
513
+
514
+ stopResizeInteractionEvent(e);
515
+ const ownerDocument = dragOwnerDocumentRef.current || document;
516
+
517
+ ownerDocument.removeEventListener('mousemove', handleDragMove, true);
518
+ ownerDocument.removeEventListener('mouseup', handleDragEnd, true);
519
+ suppressNextResizeClick(ownerDocument);
465
520
 
466
- const handleDragEnd = useCallback(() => {
467
521
  isDraggingRef.current = false;
468
522
  dragTypeRef.current = null;
469
523
  dragStartPosRef.current = { x: 0, y: 0 };
470
-
471
- document.removeEventListener('mousemove', handleDragMove);
472
- document.removeEventListener('mouseup', handleDragEnd);
524
+ dragOwnerDocumentRef.current = null;
473
525
 
474
526
  props.model.parent.emitter.emit('onResizeEnd');
475
527
  onDragEnd?.();
476
- }, [handleDragMove, onDragEnd, props.model]);
528
+ });
529
+
530
+ useEffect(() => {
531
+ return () => {
532
+ const dragOwnerDocument = dragOwnerDocumentRef.current;
533
+ dragOwnerDocument?.removeEventListener('mousemove', handleDragMove, true);
534
+ dragOwnerDocument?.removeEventListener('mouseup', handleDragEnd, true);
535
+
536
+ if (isDraggingRef.current) {
537
+ suppressNextResizeClick(dragOwnerDocument || document);
538
+ props.model.parent.emitter.emit('onResizeEnd');
539
+ onDragEnd?.();
540
+ }
477
541
 
478
- const handleDragStart = useCallback(
479
- (e: React.MouseEvent, type: 'left' | 'right') => {
480
- e.preventDefault();
481
- e.stopPropagation();
542
+ isDraggingRef.current = false;
543
+ dragTypeRef.current = null;
544
+ dragStartPosRef.current = { x: 0, y: 0 };
545
+ dragOwnerDocumentRef.current = null;
546
+ };
547
+ }, [handleDragMove, handleDragEnd, onDragEnd, props.model]);
482
548
 
483
- isDraggingRef.current = true;
484
- dragTypeRef.current = type;
485
- dragStartPosRef.current = { x: e.clientX, y: e.clientY };
549
+ const handleDragStart = useMemoizedFn((e: React.MouseEvent, type: 'left' | 'right') => {
550
+ stopResizeInteractionEvent(e);
551
+ const ownerDocument = e.currentTarget.ownerDocument;
486
552
 
487
- document.addEventListener('mousemove', handleDragMove);
488
- document.addEventListener('mouseup', handleDragEnd);
553
+ isDraggingRef.current = true;
554
+ dragTypeRef.current = type;
555
+ dragStartPosRef.current = { x: e.clientX, y: e.clientY };
556
+ dragOwnerDocumentRef.current = ownerDocument;
489
557
 
490
- onDragStart?.();
491
- },
492
- [handleDragMove, handleDragEnd, onDragStart],
493
- );
558
+ ownerDocument.addEventListener('mousemove', handleDragMove, true);
559
+ ownerDocument.addEventListener('mouseup', handleDragEnd, true);
560
+
561
+ onDragStart?.();
562
+ });
494
563
 
495
564
  return (
496
565
  <>
@@ -601,6 +670,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
601
670
  getPopupContainer,
602
671
  handleSettingsMenuOpenChange,
603
672
  model,
673
+ modelUid,
604
674
  settingsMenuLevel,
605
675
  showCopyUidButton,
606
676
  showDeleteButton,
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import React from 'react';
11
- import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react';
11
+ import { act, cleanup, fireEvent, render, waitFor, within } from '@testing-library/react';
12
12
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
13
  import { App, ConfigProvider } from 'antd';
14
14
  import { FlowEngine } from '../../../../../flowEngine';
@@ -17,7 +17,7 @@ import { FlowEngineProvider } from '../../../../../provider';
17
17
  import { FieldModelRenderer } from '../../../../FieldModelRenderer';
18
18
  import { FlowModelRenderer } from '../../../../FlowModelRenderer';
19
19
  import { FlowsFloatContextMenu } from '../FlowsFloatContextMenu';
20
- import { TOOLBAR_DRAG_ACTIVITY_EVENT } from '../../../../dnd';
20
+ import { DndProvider, DragHandler, TOOLBAR_DRAG_ACTIVITY_EVENT } from '../../../../dnd';
21
21
 
22
22
  const mockColorTextTertiary = '#8c8c8c';
23
23
 
@@ -182,12 +182,24 @@ const ToolbarDragItem = ({ model }: { model: FlowModel }) => {
182
182
  );
183
183
  };
184
184
 
185
+ const ForkedToolbarDragItem = ({ model }: { model: FlowModel }) => {
186
+ return (
187
+ <DragHandler model={model}>
188
+ <button type="button" aria-label="forked-toolbar-drag">
189
+ drag
190
+ </button>
191
+ </DragHandler>
192
+ );
193
+ };
194
+
185
195
  describe('FlowsFloatContextMenu', () => {
186
196
  const originalResizeObserver = globalThis.ResizeObserver;
187
197
  const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
188
198
  const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
199
+ const originalPointerEvent = globalThis.PointerEvent;
189
200
 
190
201
  beforeEach(() => {
202
+ globalThis.PointerEvent = MouseEvent as typeof PointerEvent;
191
203
  globalThis.ResizeObserver = class {
192
204
  observe = vi.fn();
193
205
  disconnect = vi.fn();
@@ -207,6 +219,7 @@ describe('FlowsFloatContextMenu', () => {
207
219
  globalThis.ResizeObserver = originalResizeObserver;
208
220
  globalThis.requestAnimationFrame = originalRequestAnimationFrame;
209
221
  globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
222
+ globalThis.PointerEvent = originalPointerEvent;
210
223
  document.body.innerHTML = '';
211
224
  });
212
225
 
@@ -545,28 +558,163 @@ describe('FlowsFloatContextMenu', () => {
545
558
  fireEvent.mouseEnter(icons, { relatedTarget: host });
546
559
 
547
560
  const dragButton = within(overlay).getByLabelText('toolbar-drag');
548
- dragButton.ownerDocument.dispatchEvent(
549
- new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
550
- detail: { active: true, modelUid: model.uid },
551
- }),
552
- );
561
+ act(() => {
562
+ dragButton.ownerDocument.dispatchEvent(
563
+ new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
564
+ detail: { active: true, modelUid: model.uid },
565
+ }),
566
+ );
567
+ });
553
568
  fireEvent.mouseLeave(icons, { relatedTarget: document.createElement('div') });
554
569
 
555
570
  await waitFor(() => {
556
571
  expect(queryOverlay(appContainer, 'drag-toolbar-model')?.className).toContain('nb-toolbar-visible');
557
572
  });
558
573
 
559
- dragButton.ownerDocument.dispatchEvent(
560
- new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
561
- detail: { active: false, modelUid: model.uid },
562
- }),
563
- );
574
+ act(() => {
575
+ dragButton.ownerDocument.dispatchEvent(
576
+ new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
577
+ detail: { active: false, modelUid: model.uid },
578
+ }),
579
+ );
580
+ });
564
581
 
565
582
  await waitFor(() => {
566
583
  expect(queryOverlay(appContainer, 'drag-toolbar-model')).toBeNull();
567
584
  });
568
585
  });
569
586
 
587
+ it('does not let resize handle drag release close the surrounding modal wrap', async () => {
588
+ const engine = new FlowEngine();
589
+ await engine.flowSettings.forceEnable();
590
+ const parentModel = createModel(engine, 'resize-parent-model');
591
+ const model = createModel(engine, 'resize-child-model');
592
+ model.setParent(parentModel);
593
+ const appContainer = createAppContainer();
594
+ const modalWrap = createPopupRoot('ant-modal-wrap');
595
+ const onModalWrapClick = vi.fn();
596
+ modalWrap.addEventListener('click', onModalWrapClick);
597
+ appContainer.appendChild(modalWrap);
598
+ mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
599
+ mockRect(modalWrap, { top: 100, left: 200, width: 640, height: 520 });
600
+
601
+ const { getByTestId } = renderWithProviders(
602
+ engine,
603
+ <FlowsFloatContextMenu model={model} showDragHandle>
604
+ <div data-testid="resize-content">content</div>
605
+ </FlowsFloatContextMenu>,
606
+ { container: modalWrap },
607
+ );
608
+
609
+ const host = getHost(getByTestId('resize-content'));
610
+ mockRect(host, { top: 140, left: 280, width: 220, height: 48 });
611
+
612
+ fireEvent.mouseEnter(host);
613
+
614
+ const overlay = await waitFor(() => {
615
+ const nextOverlay = modalWrap.querySelector('[data-model-uid="resize-child-model"]') as HTMLDivElement | null;
616
+ expect(nextOverlay).toBeTruthy();
617
+ return nextOverlay as HTMLDivElement;
618
+ });
619
+
620
+ const resizeHandle = overlay.querySelector('.resize-handle-right') as HTMLDivElement;
621
+ expect(resizeHandle).toBeTruthy();
622
+
623
+ fireEvent.mouseDown(resizeHandle, { clientX: 500, clientY: 164 });
624
+ fireEvent.mouseMove(document, { clientX: 460, clientY: 164 });
625
+ fireEvent.mouseUp(modalWrap, { clientX: 460, clientY: 164 });
626
+ fireEvent.click(modalWrap, { clientX: 460, clientY: 164 });
627
+
628
+ expect(onModalWrapClick).not.toHaveBeenCalled();
629
+ });
630
+
631
+ it('emits resize end when resize handles unmount during a drag', async () => {
632
+ const engine = new FlowEngine();
633
+ await engine.flowSettings.forceEnable();
634
+ const parentModel = createModel(engine, 'resize-unmount-parent-model');
635
+ const model = createModel(engine, 'resize-unmount-child-model');
636
+ model.setParent(parentModel);
637
+ const appContainer = createAppContainer();
638
+ const onResizeEnd = vi.fn();
639
+ parentModel.emitter.on('onResizeEnd', onResizeEnd);
640
+ mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
641
+
642
+ const { getByTestId, unmount } = renderWithProviders(
643
+ engine,
644
+ <FlowsFloatContextMenu model={model} showDragHandle>
645
+ <div data-testid="resize-unmount-content">content</div>
646
+ </FlowsFloatContextMenu>,
647
+ { container: appContainer },
648
+ );
649
+
650
+ const host = getHost(getByTestId('resize-unmount-content'));
651
+ mockRect(host, { top: 140, left: 280, width: 220, height: 48 });
652
+
653
+ fireEvent.mouseEnter(host);
654
+
655
+ const overlay = await waitFor(() => {
656
+ const nextOverlay = appContainer.querySelector(
657
+ '[data-model-uid="resize-unmount-child-model"]',
658
+ ) as HTMLDivElement | null;
659
+ expect(nextOverlay).toBeTruthy();
660
+ return nextOverlay as HTMLDivElement;
661
+ });
662
+
663
+ const resizeHandle = overlay.querySelector('.resize-handle-right') as HTMLDivElement;
664
+ expect(resizeHandle).toBeTruthy();
665
+
666
+ fireEvent.mouseDown(resizeHandle, { clientX: 500, clientY: 164 });
667
+ unmount();
668
+
669
+ expect(onResizeEnd).toHaveBeenCalledTimes(1);
670
+ });
671
+
672
+ it('keeps the resize release click suppressed after handles unmount', async () => {
673
+ const engine = new FlowEngine();
674
+ await engine.flowSettings.forceEnable();
675
+ const parentModel = createModel(engine, 'resize-release-unmount-parent-model');
676
+ const model = createModel(engine, 'resize-release-unmount-child-model');
677
+ model.setParent(parentModel);
678
+ const appContainer = createAppContainer();
679
+ const modalWrap = createPopupRoot('ant-modal-wrap');
680
+ const onModalWrapClick = vi.fn();
681
+ modalWrap.addEventListener('click', onModalWrapClick);
682
+ appContainer.appendChild(modalWrap);
683
+ mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
684
+ mockRect(modalWrap, { top: 100, left: 200, width: 640, height: 520 });
685
+
686
+ const { getByTestId, unmount } = renderWithProviders(
687
+ engine,
688
+ <FlowsFloatContextMenu model={model} showDragHandle>
689
+ <div data-testid="resize-release-unmount-content">content</div>
690
+ </FlowsFloatContextMenu>,
691
+ { container: modalWrap },
692
+ );
693
+
694
+ const host = getHost(getByTestId('resize-release-unmount-content'));
695
+ mockRect(host, { top: 140, left: 280, width: 220, height: 48 });
696
+
697
+ fireEvent.mouseEnter(host);
698
+
699
+ const overlay = await waitFor(() => {
700
+ const nextOverlay = modalWrap.querySelector(
701
+ '[data-model-uid="resize-release-unmount-child-model"]',
702
+ ) as HTMLDivElement | null;
703
+ expect(nextOverlay).toBeTruthy();
704
+ return nextOverlay as HTMLDivElement;
705
+ });
706
+
707
+ const resizeHandle = overlay.querySelector('.resize-handle-right') as HTMLDivElement;
708
+ expect(resizeHandle).toBeTruthy();
709
+
710
+ fireEvent.mouseDown(resizeHandle, { clientX: 500, clientY: 164 });
711
+ fireEvent.mouseUp(modalWrap, { clientX: 460, clientY: 164 });
712
+ unmount();
713
+ fireEvent.click(modalWrap, { clientX: 460, clientY: 164 });
714
+
715
+ expect(onModalWrapClick).not.toHaveBeenCalled();
716
+ });
717
+
570
718
  it('hides parent toolbar when hovering a nested child host', async () => {
571
719
  const engine = new FlowEngine();
572
720
  await engine.flowSettings.forceEnable();
@@ -714,6 +862,122 @@ describe('FlowsFloatContextMenu', () => {
714
862
  });
715
863
  });
716
864
 
865
+ it('restores parent toolbar when stale child activity remains but pointer is on the parent block', async () => {
866
+ const engine = new FlowEngine();
867
+ await engine.flowSettings.forceEnable();
868
+ const parentModel = createModel(engine, 'parent-stale-child-model');
869
+ const childModel = createModel(engine, 'child-stale-model');
870
+ const appContainer = createAppContainer();
871
+ mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
872
+
873
+ const { getByTestId } = renderWithProviders(
874
+ engine,
875
+ <FlowsFloatContextMenu model={parentModel}>
876
+ <div data-testid="parent-content">
877
+ <div data-testid="parent-gap">gap</div>
878
+ <FlowsFloatContextMenu model={childModel}>
879
+ <div data-testid="child-content">child</div>
880
+ </FlowsFloatContextMenu>
881
+ </div>
882
+ </FlowsFloatContextMenu>,
883
+ { container: appContainer },
884
+ );
885
+
886
+ const parentHost = getHost(getByTestId('parent-content'));
887
+ const childHost = getHost(getByTestId('child-content'));
888
+ const parentGap = getByTestId('parent-gap');
889
+ mockRect(parentHost, { top: 10, left: 10, width: 320, height: 160 });
890
+ mockRect(childHost, { top: 28, left: 36, width: 120, height: 48 });
891
+
892
+ fireEvent.mouseEnter(parentHost);
893
+
894
+ const parentOverlay = await waitFor(() => {
895
+ const nextOverlay = queryOverlay(appContainer, 'parent-stale-child-model');
896
+ expect(nextOverlay).toBeTruthy();
897
+ return nextOverlay as HTMLDivElement;
898
+ });
899
+
900
+ await waitFor(() => {
901
+ expect(within(parentOverlay).getByLabelText('flows-settings')).toBeTruthy();
902
+ });
903
+
904
+ act(() => {
905
+ childHost.dispatchEvent(
906
+ new CustomEvent('nb-float-menu-child-activity', {
907
+ bubbles: true,
908
+ detail: { active: true, modelUid: childModel.uid },
909
+ }),
910
+ );
911
+ });
912
+
913
+ await waitFor(() => {
914
+ expect(queryOverlay(appContainer, 'parent-stale-child-model')).toBeNull();
915
+ });
916
+
917
+ fireEvent.mouseMove(parentGap);
918
+
919
+ await waitFor(() => {
920
+ const parentOverlayAfterRestore = queryOverlay(appContainer, 'parent-stale-child-model');
921
+ expect(parentOverlayAfterRestore).toBeTruthy();
922
+ expect(parentOverlayAfterRestore?.className).toContain('nb-toolbar-visible');
923
+ });
924
+ });
925
+
926
+ it('keeps parent toolbar hidden when moving over the parent block while child toolbar is active', async () => {
927
+ const engine = new FlowEngine();
928
+ await engine.flowSettings.forceEnable();
929
+ const parentModel = createModel(engine, 'parent-active-child-model');
930
+ const childModel = createModel(engine, 'child-active-model');
931
+ const appContainer = createAppContainer();
932
+ mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
933
+
934
+ const { getByTestId } = renderWithProviders(
935
+ engine,
936
+ <FlowsFloatContextMenu model={parentModel}>
937
+ <div data-testid="parent-content">
938
+ <div data-testid="parent-gap">gap</div>
939
+ <FlowsFloatContextMenu model={childModel}>
940
+ <div data-testid="child-content">child</div>
941
+ </FlowsFloatContextMenu>
942
+ </div>
943
+ </FlowsFloatContextMenu>,
944
+ { container: appContainer },
945
+ );
946
+
947
+ const parentHost = getHost(getByTestId('parent-content'));
948
+ const childHost = getHost(getByTestId('child-content'));
949
+ const parentGap = getByTestId('parent-gap');
950
+ mockRect(parentHost, { top: 10, left: 10, width: 320, height: 160 });
951
+ mockRect(childHost, { top: 28, left: 36, width: 120, height: 48 });
952
+
953
+ fireEvent.mouseEnter(parentHost);
954
+
955
+ await waitFor(() => {
956
+ expect(queryOverlay(appContainer, 'parent-active-child-model')).toBeTruthy();
957
+ });
958
+
959
+ fireEvent.mouseEnter(childHost);
960
+ fireEvent.mouseMove(childHost);
961
+
962
+ const childOverlay = await waitFor(() => {
963
+ const nextOverlay = queryOverlay(appContainer, 'child-active-model');
964
+ expect(nextOverlay).toBeTruthy();
965
+ return nextOverlay as HTMLDivElement;
966
+ });
967
+
968
+ await waitFor(() => {
969
+ expect(childOverlay.className).toContain('nb-toolbar-visible');
970
+ expect(queryOverlay(appContainer, 'parent-active-child-model')).toBeNull();
971
+ });
972
+
973
+ fireEvent.mouseMove(parentGap);
974
+
975
+ await waitFor(() => {
976
+ expect(queryOverlay(appContainer, 'child-active-model')?.className).toContain('nb-toolbar-visible');
977
+ expect(queryOverlay(appContainer, 'parent-active-child-model')).toBeNull();
978
+ });
979
+ });
980
+
717
981
  it('treats forked models as distinct float menu instances even when they share the same uid', async () => {
718
982
  const engine = new FlowEngine();
719
983
  await engine.flowSettings.forceEnable();
@@ -775,4 +1039,109 @@ describe('FlowsFloatContextMenu', () => {
775
1039
  expect(firstOverlay.getAttribute('data-model-uid')).toBe(firstInstanceId);
776
1040
  expect(secondOverlay.getAttribute('data-model-uid')).toBe(secondInstanceId);
777
1041
  });
1042
+
1043
+ it('keeps a forked child toolbar active while dragging one of its toolbar items', async () => {
1044
+ const engine = new FlowEngine();
1045
+ await engine.flowSettings.forceEnable();
1046
+ const parentModel = createModel(engine, 'parent-forked-drag-model');
1047
+ const childMasterModel = new FlowModel({ uid: 'forked-drag-child-model', flowEngine: engine });
1048
+ childMasterModel.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
1049
+ const childFork = childMasterModel.createFork({}, 'drag-card') as FlowModel & { forkId?: string };
1050
+ const childInstanceId = `forked-drag-child-model::${String(childFork.forkId)}`;
1051
+ const appContainer = createAppContainer();
1052
+ mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
1053
+
1054
+ const { getByTestId } = renderWithProviders(
1055
+ engine,
1056
+ <DndProvider>
1057
+ <FlowsFloatContextMenu model={parentModel}>
1058
+ <div data-testid="parent-content">
1059
+ <div data-testid="parent-gap">gap</div>
1060
+ <FlowsFloatContextMenu
1061
+ model={childFork}
1062
+ extraToolbarItems={[
1063
+ {
1064
+ key: 'forked-toolbar-drag',
1065
+ component: ForkedToolbarDragItem,
1066
+ sort: 100,
1067
+ },
1068
+ ]}
1069
+ >
1070
+ <div data-testid="forked-child-content">child</div>
1071
+ </FlowsFloatContextMenu>
1072
+ </div>
1073
+ </FlowsFloatContextMenu>
1074
+ </DndProvider>,
1075
+ { container: appContainer },
1076
+ );
1077
+
1078
+ const parentHost = getHost(getByTestId('parent-content'));
1079
+ const childHost = getHost(getByTestId('forked-child-content'));
1080
+ const parentGap = getByTestId('parent-gap');
1081
+ mockRect(parentHost, { top: 10, left: 10, width: 320, height: 160 });
1082
+ mockRect(childHost, { top: 28, left: 36, width: 120, height: 48 });
1083
+
1084
+ fireEvent.mouseEnter(parentHost);
1085
+
1086
+ await waitFor(() => {
1087
+ expect(queryOverlay(appContainer, 'parent-forked-drag-model')).toBeTruthy();
1088
+ });
1089
+
1090
+ fireEvent.mouseEnter(childHost);
1091
+ fireEvent.mouseMove(childHost);
1092
+
1093
+ const childOverlay = await waitFor(() => {
1094
+ const nextOverlay = queryOverlay(appContainer, childInstanceId);
1095
+ expect(nextOverlay).toBeTruthy();
1096
+ return nextOverlay as HTMLDivElement;
1097
+ });
1098
+
1099
+ await waitFor(() => {
1100
+ expect(queryOverlay(appContainer, 'parent-forked-drag-model')).toBeNull();
1101
+ expect(within(childOverlay).getByLabelText('forked-toolbar-drag')).toBeTruthy();
1102
+ });
1103
+
1104
+ const childIcons = childOverlay.querySelector('.nb-toolbar-container-icons') as HTMLDivElement;
1105
+ fireEvent.mouseLeave(parentHost, { relatedTarget: childIcons });
1106
+ fireEvent.mouseLeave(childHost, { relatedTarget: childIcons });
1107
+ fireEvent.mouseEnter(childIcons, { relatedTarget: childHost });
1108
+
1109
+ const dragButton = within(childOverlay).getByLabelText('forked-toolbar-drag');
1110
+ const dragActivityListener = vi.fn();
1111
+ dragButton.ownerDocument.addEventListener(TOOLBAR_DRAG_ACTIVITY_EVENT, dragActivityListener);
1112
+ fireEvent.pointerDown(dragButton.parentElement as HTMLElement, { button: 0, clientX: 48, clientY: 36 });
1113
+
1114
+ expect(dragActivityListener).toHaveBeenCalledWith(
1115
+ expect.objectContaining({
1116
+ detail: expect.objectContaining({ active: true, modelUid: childInstanceId }),
1117
+ }),
1118
+ );
1119
+ dragButton.ownerDocument.removeEventListener(TOOLBAR_DRAG_ACTIVITY_EVENT, dragActivityListener);
1120
+
1121
+ vi.useFakeTimers();
1122
+ const querySelectorAllSpy = vi.spyOn(parentGap.ownerDocument, 'querySelectorAll');
1123
+
1124
+ try {
1125
+ fireEvent.mouseLeave(childIcons, { relatedTarget: parentGap });
1126
+ fireEvent.mouseEnter(parentHost, { relatedTarget: childIcons });
1127
+ fireEvent.mouseEnter(parentGap, { relatedTarget: childIcons });
1128
+ fireEvent.mouseMove(parentGap);
1129
+
1130
+ expect(querySelectorAllSpy).not.toHaveBeenCalled();
1131
+
1132
+ await act(async () => {
1133
+ await vi.advanceTimersByTimeAsync(250);
1134
+ });
1135
+ } finally {
1136
+ querySelectorAllSpy.mockRestore();
1137
+ vi.useRealTimers();
1138
+ }
1139
+
1140
+ await waitFor(() => {
1141
+ const childOverlayAfterDrag = queryOverlay(appContainer, childInstanceId);
1142
+ expect(childOverlayAfterDrag).toBeTruthy();
1143
+ expect(childOverlayAfterDrag?.className).toContain('nb-toolbar-visible');
1144
+ expect(queryOverlay(appContainer, 'parent-forked-drag-model')).toBeNull();
1145
+ });
1146
+ });
778
1147
  });
@@ -47,6 +47,25 @@ const getToolbarModelUidFromTarget = (target: EventTarget | null): string | null
47
47
  return target.closest('.nb-toolbar-container[data-model-uid]')?.getAttribute('data-model-uid') || null;
48
48
  };
49
49
 
50
+ const escapeCssAttributeValue = (value: string): string => {
51
+ return value
52
+ .replace(/\\/g, '\\\\')
53
+ .replace(/"/g, '\\"')
54
+ .replace(/\n/g, '\\a ')
55
+ .replace(/\r/g, '\\d ')
56
+ .replace(/\f/g, '\\c ');
57
+ };
58
+
59
+ const isToolbarModelUidRendered = (ownerDocument: Document | null, modelUid: string): boolean => {
60
+ if (!ownerDocument) {
61
+ return false;
62
+ }
63
+
64
+ return !!ownerDocument.querySelector<HTMLElement>(
65
+ `.nb-toolbar-container[data-model-uid="${escapeCssAttributeValue(modelUid)}"]`,
66
+ );
67
+ };
68
+
50
69
  const isNodeWithinDescendantFloatToolbar = (
51
70
  target: EventTarget | null,
52
71
  container: HTMLElement | null,
@@ -264,6 +283,15 @@ export const useFloatToolbarVisibility = ({
264
283
 
265
284
  if (isCurrentHostTarget) {
266
285
  clearHideToolbarTimer();
286
+ setActiveChildToolbarIds((prevIds) => {
287
+ if (!prevIds.length) {
288
+ return prevIds;
289
+ }
290
+
291
+ const ownerDocument = containerRef.current?.ownerDocument ?? null;
292
+ const nextIds = prevIds.filter((id) => isToolbarModelUidRendered(ownerDocument, id));
293
+ return nextIds.length === prevIds.length ? prevIds : nextIds;
294
+ });
267
295
  setHostHovered(true);
268
296
  }
269
297
 
@@ -3429,7 +3429,19 @@ export class FlowEngineContext extends BaseFlowEngineContext {
3429
3429
  }),
3430
3430
  });
3431
3431
  this.defineProperty('role', {
3432
- get: () => this.api?.auth?.role,
3432
+ get: () => {
3433
+ const currentRole = this.api?.auth?.role;
3434
+ if (currentRole !== '__union__') {
3435
+ return currentRole;
3436
+ }
3437
+ const roles = this.user?.roles;
3438
+ if (!Array.isArray(roles)) {
3439
+ return [];
3440
+ }
3441
+ return roles
3442
+ .map((role: { name?: string }) => role?.name)
3443
+ .filter((name: string | undefined): name is string => !!name);
3444
+ },
3433
3445
  cache: false,
3434
3446
  // 注意:使用惰性 meta 工厂,避免在 i18n 尚未注入时提前求值导致无法翻译
3435
3447
  meta: Object.assign(() => ({ type: 'string', title: this.t('Current role'), sort: 990 }), {