@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.
- package/lib/components/dnd/index.js +9 -2
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
- package/lib/flowContext.js +10 -2
- package/package.json +4 -4
- package/src/__tests__/flowContext.test.ts +43 -0
- package/src/components/dnd/index.tsx +11 -2
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
- package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
- package/src/flowContext.ts +13 -1
|
@@ -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 =
|
|
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:
|
|
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,
|
|
331
|
-
(
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
354
|
-
|
|
355
|
-
(
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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);
|
package/lib/flowContext.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
12
|
-
"@nocobase/shared": "2.2.0-beta.
|
|
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": "
|
|
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 =
|
|
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:
|
|
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 =
|
|
449
|
-
(
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
549
|
+
const handleDragStart = useMemoizedFn((e: React.MouseEvent, type: 'left' | 'right') => {
|
|
550
|
+
stopResizeInteractionEvent(e);
|
|
551
|
+
const ownerDocument = e.currentTarget.ownerDocument;
|
|
486
552
|
|
|
487
|
-
|
|
488
|
-
|
|
553
|
+
isDraggingRef.current = true;
|
|
554
|
+
dragTypeRef.current = type;
|
|
555
|
+
dragStartPosRef.current = { x: e.clientX, y: e.clientY };
|
|
556
|
+
dragOwnerDocumentRef.current = ownerDocument;
|
|
489
557
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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,
|
package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx
CHANGED
|
@@ -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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
package/src/flowContext.ts
CHANGED
|
@@ -3429,7 +3429,19 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
3429
3429
|
}),
|
|
3430
3430
|
});
|
|
3431
3431
|
this.defineProperty('role', {
|
|
3432
|
-
get: () =>
|
|
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 }), {
|