@nocobase/flow-engine 2.1.0-beta.10 → 2.1.0-beta.12

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.
Files changed (36) hide show
  1. package/lib/components/FlowModelRenderer.d.ts +1 -1
  2. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  3. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +48 -9
  4. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +19 -43
  5. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +332 -296
  6. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  7. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +272 -0
  8. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  9. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +247 -0
  10. package/lib/components/subModel/AddSubModelButton.js +11 -0
  11. package/lib/flowContext.js +27 -0
  12. package/lib/runjs-context/setup.js +1 -0
  13. package/lib/runjs-context/snippets/index.js +13 -2
  14. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  15. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  16. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  17. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  18. package/package.json +5 -4
  19. package/src/__tests__/flowContext.test.ts +65 -1
  20. package/src/__tests__/runjsContext.test.ts +3 -0
  21. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  22. package/src/__tests__/runjsSnippets.test.ts +21 -0
  23. package/src/components/FlowModelRenderer.tsx +3 -1
  24. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +17 -7
  25. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +63 -9
  26. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +457 -440
  27. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +95 -0
  28. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +547 -0
  29. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +358 -0
  30. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +281 -0
  31. package/src/components/subModel/AddSubModelButton.tsx +15 -1
  32. package/src/flowContext.ts +30 -0
  33. package/src/runjs-context/setup.ts +1 -0
  34. package/src/runjs-context/snippets/index.ts +12 -1
  35. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  36. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
11
+ import { createPortal } from 'react-dom';
11
12
  import { Alert, Space } from 'antd';
12
13
  import { css } from '@emotion/css';
13
14
  import { FlowModel } from '../../../../models';
@@ -18,16 +19,255 @@ import { FlowEngine } from '../../../../flowEngine';
18
19
  import { getT } from '../../../../utils';
19
20
  import { useFlowContext } from '../../../..';
20
21
  import { observer } from '../../../../reactive';
22
+ import {
23
+ omitToolbarPortalInsetStyle,
24
+ ToolbarPortalRect,
25
+ ToolbarPortalRenderSnapshot,
26
+ useFloatToolbarPortal,
27
+ } from './useFloatToolbarPortal';
28
+ import { useFloatToolbarVisibility } from './useFloatToolbarVisibility';
21
29
 
22
- // 检测DOM中直接子元素是否包含button元素的辅助函数
30
+ const TOOLBAR_Z_INDEX = 999;
31
+
32
+ type ToolbarPosition = 'inside' | 'above' | 'below';
33
+
34
+ interface BaseFloatContextMenuProps {
35
+ children?: React.ReactNode;
36
+ enabled?: boolean;
37
+ showDeleteButton?: boolean;
38
+ showCopyUidButton?: boolean;
39
+ containerStyle?: React.CSSProperties;
40
+ /** 自定义工具栏样式,`top/left/right/bottom` 会作为 portal overlay 的 inset 使用。 */
41
+ toolbarStyle?: React.CSSProperties;
42
+ className?: string;
43
+ /**
44
+ * @default true
45
+ */
46
+ showBorder?: boolean;
47
+ /**
48
+ * @default true
49
+ */
50
+ showBackground?: boolean;
51
+ /**
52
+ * @default false
53
+ */
54
+ showTitle?: boolean;
55
+ /**
56
+ * @default false
57
+ */
58
+ showDragHandle?: boolean;
59
+ /**
60
+ * Settings menu levels: 1=current model only (default), 2=include sub-models
61
+ */
62
+ settingsMenuLevel?: number;
63
+ /**
64
+ * Extra toolbar items to add to this context menu instance
65
+ */
66
+ extraToolbarItems?: ToolbarItemConfig[];
67
+ /**
68
+ * @default 'inside'
69
+ */
70
+ toolbarPosition?: ToolbarPosition;
71
+ }
72
+
73
+ const hostContainerStyles = css`
74
+ position: relative;
75
+
76
+ &.has-button-child {
77
+ display: inline-block;
78
+ }
79
+ `;
80
+
81
+ const toolbarPositionClassNames: Record<ToolbarPosition, string> = {
82
+ inside: 'nb-toolbar-position-inside',
83
+ above: 'nb-toolbar-position-above',
84
+ below: 'nb-toolbar-position-below',
85
+ };
86
+
87
+ const toolbarContainerStyles = ({
88
+ showBackground,
89
+ showBorder,
90
+ ctx,
91
+ }: {
92
+ showBackground: boolean;
93
+ showBorder: boolean;
94
+ ctx: any;
95
+ }) => css`
96
+ z-index: ${TOOLBAR_Z_INDEX};
97
+ opacity: 0;
98
+ pointer-events: none;
99
+ overflow: visible;
100
+ transition: opacity 0.12s ease;
101
+ background: ${showBackground ? 'var(--colorBgSettingsHover)' : 'transparent'};
102
+ border: ${showBorder ? '2px solid var(--colorBorderSettingsHover)' : 'none'};
103
+ border-radius: ${ctx.themeToken.borderRadiusLG}px;
104
+
105
+ &.nb-toolbar-visible {
106
+ opacity: 1;
107
+ transition-delay: 0.1s;
108
+ }
109
+
110
+ &.nb-toolbar-portal {
111
+ top: 0;
112
+ left: 0;
113
+ }
114
+
115
+ &.nb-toolbar-portal-fixed {
116
+ position: fixed;
117
+ }
118
+
119
+ &.nb-toolbar-portal-absolute {
120
+ position: absolute;
121
+ }
122
+
123
+ &.nb-in-template {
124
+ background: var(--colorTemplateBgSettingsHover);
125
+ }
126
+
127
+ > .nb-toolbar-container-title {
128
+ pointer-events: none;
129
+ position: absolute;
130
+ top: 2px;
131
+ left: 2px;
132
+ display: flex;
133
+ align-items: center;
134
+ gap: 4px;
135
+ height: 16px;
136
+ padding: 0;
137
+ font-size: 12px;
138
+ line-height: 16px;
139
+ border-bottom-right-radius: 2px;
140
+ border-radius: 2px;
141
+
142
+ .title-tag {
143
+ display: inline-flex;
144
+ padding: 0 3px;
145
+ border-radius: 2px;
146
+ background: var(--colorSettings);
147
+ color: #fff;
148
+ }
149
+ }
150
+
151
+ > .nb-toolbar-container-icons {
152
+ display: none;
153
+ position: absolute;
154
+ right: 2px;
155
+ line-height: 16px;
156
+ pointer-events: all;
157
+
158
+ &.nb-toolbar-position-inside {
159
+ top: 2px;
160
+ }
161
+
162
+ &.nb-toolbar-position-above {
163
+ top: 0;
164
+ transform: translateY(-100%);
165
+ padding-bottom: 0;
166
+ margin-bottom: -2px;
167
+ }
168
+
169
+ &.nb-toolbar-position-below {
170
+ top: 0;
171
+ transform: translateY(100%);
172
+ padding-top: 2px;
173
+ margin-top: -2px;
174
+ }
175
+
176
+ .ant-space-item {
177
+ display: flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ width: 16px;
181
+ height: 16px;
182
+ padding: 2px;
183
+ line-height: 16px;
184
+ background-color: var(--colorSettings);
185
+ color: #fff;
186
+ }
187
+ }
188
+
189
+ &.nb-toolbar-visible > .nb-toolbar-container-icons {
190
+ display: block;
191
+ }
192
+
193
+ > .resize-handle {
194
+ position: absolute;
195
+ display: flex;
196
+ align-items: center;
197
+ justify-content: center;
198
+ pointer-events: all;
199
+ opacity: 0.6;
200
+ border-radius: 4px;
201
+ background: var(--colorSettings);
202
+
203
+ &:hover {
204
+ opacity: 0.9;
205
+ background: var(--colorSettingsHover, var(--colorSettings));
206
+ }
207
+
208
+ &::before {
209
+ content: '';
210
+ position: absolute;
211
+ border-radius: 50%;
212
+ background: rgba(255, 255, 255, 0.9);
213
+ }
214
+
215
+ &::after {
216
+ content: '';
217
+ position: absolute;
218
+ border-radius: 50%;
219
+ background: rgba(255, 255, 255, 0.9);
220
+ }
221
+ }
222
+
223
+ > .resize-handle-left {
224
+ top: 50%;
225
+ left: -4px;
226
+ width: 6px;
227
+ height: 20px;
228
+ cursor: ew-resize;
229
+ transform: translateY(-50%);
230
+
231
+ &::before {
232
+ top: 6px;
233
+ left: 50%;
234
+ width: 2px;
235
+ height: 2px;
236
+ transform: translateX(-50%);
237
+ box-shadow:
238
+ 0 4px 0 rgba(255, 255, 255, 0.9),
239
+ 0 8px 0 rgba(255, 255, 255, 0.9);
240
+ }
241
+ }
242
+
243
+ > .resize-handle-right {
244
+ top: 50%;
245
+ right: -4px;
246
+ width: 6px;
247
+ height: 20px;
248
+ cursor: ew-resize;
249
+ transform: translateY(-50%);
250
+
251
+ &::before {
252
+ top: 6px;
253
+ left: 50%;
254
+ width: 2px;
255
+ height: 2px;
256
+ transform: translateX(-50%);
257
+ box-shadow:
258
+ 0 4px 0 rgba(255, 255, 255, 0.9),
259
+ 0 8px 0 rgba(255, 255, 255, 0.9);
260
+ }
261
+ }
262
+ `;
263
+
264
+ // 检测直接子节点里是否有按钮,保留原来的 inline-block 兼容行为。
23
265
  const detectButtonInDOM = (container: HTMLElement): boolean => {
24
266
  if (!container) return false;
25
267
 
26
- // 只检测直接子元素中的button
27
268
  const directChildren = container.children;
28
269
  for (let i = 0; i < directChildren.length; i++) {
29
270
  const child = directChildren[i];
30
- // 检查是否是button元素或具有button特征的元素
31
271
  if (child.tagName === 'BUTTON' || child.getAttribute('role') === 'button' || child.classList.contains('ant-btn')) {
32
272
  return true;
33
273
  }
@@ -36,7 +276,7 @@ const detectButtonInDOM = (container: HTMLElement): boolean => {
36
276
  return false;
37
277
  };
38
278
 
39
- // 渲染工具栏项目的辅助函数
279
+ // 渲染工具栏项目,并让设置菜单与工具栏共享同一个 popup 容器。
40
280
  const renderToolbarItems = (
41
281
  model: FlowModel,
42
282
  showDeleteButton: boolean,
@@ -44,428 +284,175 @@ const renderToolbarItems = (
44
284
  flowEngine: FlowEngine,
45
285
  settingsMenuLevel?: number,
46
286
  extraToolbarItems?: ToolbarItemConfig[],
287
+ onSettingsMenuOpenChange?: (open: boolean) => void,
288
+ getPopupContainer?: (triggerNode?: HTMLElement) => HTMLElement,
47
289
  ) => {
48
290
  const toolbarItems = flowEngine?.flowSettings?.getToolbarItems?.() || [];
49
-
50
- // 合并额外的工具栏项目
51
291
  const allToolbarItems = [...toolbarItems, ...(extraToolbarItems || [])];
52
292
 
53
- // 按 sort 字段排序
54
293
  allToolbarItems.sort((a, b) => (a.sort || 0) - (b.sort || 0)).reverse();
55
294
 
56
295
  return allToolbarItems
57
296
  .filter((itemConfig: ToolbarItemConfig) => {
58
- // 检查项目是否应该显示
59
297
  return itemConfig.visible ? itemConfig.visible(model) : true;
60
298
  })
61
299
  .map((itemConfig: ToolbarItemConfig) => {
62
- // 渲染项目组件
63
300
  const ItemComponent = itemConfig.component;
64
301
 
65
- // 对于默认设置项目,传递额外的 props
66
302
  if (itemConfig.key === 'settings-menu') {
67
303
  return (
68
304
  <ItemComponent
69
305
  key={itemConfig.key}
70
306
  model={model}
71
- id={model.uid} // 用于拖拽的 id
307
+ id={model.uid}
72
308
  showDeleteButton={showDeleteButton}
73
309
  showCopyUidButton={showCopyUidButton}
74
310
  menuLevels={settingsMenuLevel}
311
+ onDropdownVisibleChange={onSettingsMenuOpenChange}
312
+ getPopupContainer={getPopupContainer}
75
313
  />
76
314
  );
77
315
  }
78
316
 
79
- // 其他项目只传递 model
80
317
  return <ItemComponent key={itemConfig.key} model={model} />;
81
318
  });
82
319
  };
83
320
 
84
- // Width in pixels per toolbar item (icon width + spacing)
85
- const TOOLBAR_ITEM_WIDTH = 19;
86
-
87
- const toolbarPositionToCSS = {
88
- inside: `
89
- top: 2px;
90
- `,
91
- above: `
92
- top: 0px;
93
- transform: translateY(-100%);
94
- padding-bottom: 0px;
95
- margin-bottom: -2px;
96
- `,
97
- below: `
98
- top: 0px;
99
- transform: translateY(100%);
100
- padding-top: 2px;
101
- margin-top: -2px;
102
- `,
103
- };
104
-
105
- // 使用与 NocoBase 一致的悬浮工具栏样式
106
- const floatContainerStyles = ({ showBackground, showBorder, ctx, toolbarPosition = 'inside', toolbarCount }) => css`
107
- position: relative;
108
-
109
- /* 当检测到button时使用inline-block */
110
- &.has-button-child {
111
- display: inline-block;
112
- }
113
-
114
- /* 正常的hover行为 - 添加延迟显示 */
115
- &:hover > .nb-toolbar-container {
116
- opacity: 1;
117
- transition-delay: 0.1s;
118
-
119
- .nb-toolbar-container-icons {
120
- display: block;
121
- }
122
- }
123
-
124
- /* 当有.hide-parent-menu类时隐藏菜单 */
125
- &.hide-parent-menu > .nb-toolbar-container {
126
- opacity: 0 !important;
127
- }
128
-
129
- > .nb-toolbar-container {
130
- position: absolute;
131
- top: 0;
132
- bottom: 0;
133
- left: 0;
134
- right: 0;
135
- z-index: 999;
136
- opacity: 0;
137
- background: ${showBackground ? 'var(--colorBgSettingsHover)' : ''};
138
- border: ${showBorder ? '2px solid var(--colorBorderSettingsHover)' : ''};
139
- border-radius: ${ctx.themeToken.borderRadiusLG}px;
140
- pointer-events: none;
141
- min-width: ${TOOLBAR_ITEM_WIDTH * toolbarCount}px;
142
-
143
- &.nb-in-template {
144
- background: var(--colorTemplateBgSettingsHover);
145
- }
146
-
147
- > .nb-toolbar-container-title {
148
- pointer-events: none;
149
- position: absolute;
150
- font-size: 12px;
151
- padding: 0;
152
- line-height: 16px;
153
- height: 16px;
154
- border-bottom-right-radius: 2px;
155
- border-radius: 2px;
156
- top: 2px;
157
- left: 2px;
158
- display: flex;
159
- align-items: center;
160
- gap: 4px;
161
-
162
- .title-tag {
163
- padding: 0 3px;
164
- border-radius: 2px;
165
- background: var(--colorSettings);
166
- color: #fff;
167
- display: inline-flex;
168
- }
169
- }
170
-
171
- > .nb-toolbar-container-icons {
172
- display: none; // 防止遮挡其它 icons
173
- position: absolute;
174
- right: 2px;
175
- ${toolbarPositionToCSS[toolbarPosition] || ''}
176
- line-height: 16px;
177
- pointer-events: all;
178
-
179
- .ant-space-item {
180
- background-color: var(--colorSettings);
181
- color: #fff;
182
- line-height: 16px;
183
- width: 16px;
184
- height: 16px;
185
- padding: 2px;
186
- display: flex;
187
- align-items: center;
188
- justify-content: center;
189
- }
190
- }
191
-
192
- /* 拖拽把手样式 - 参考 AirTable 样式 */
193
- > .resize-handle {
194
- position: absolute;
195
- pointer-events: all;
196
- background: var(--colorSettings);
197
- opacity: 0.6;
198
- border-radius: 4px;
199
- display: flex;
200
- align-items: center;
201
- justify-content: center;
202
-
203
- &:hover {
204
- opacity: 0.9;
205
- background: var(--colorSettingsHover, var(--colorSettings));
206
- }
207
-
208
- &::before {
209
- content: '';
210
- position: absolute;
211
- background: rgba(255, 255, 255, 0.9);
212
- border-radius: 50%;
213
- }
214
-
215
- &::after {
216
- content: '';
217
- position: absolute;
218
- background: rgba(255, 255, 255, 0.9);
219
- border-radius: 50%;
220
- }
221
- }
222
-
223
- > .resize-handle-left {
224
- left: -4px;
225
- top: 50%;
226
- transform: translateY(-50%);
227
- width: 6px;
228
- height: 20px;
229
- cursor: ew-resize;
230
-
231
- &::before {
232
- width: 2px;
233
- height: 2px;
234
- top: 6px;
235
- left: 50%;
236
- transform: translateX(-50%);
237
- box-shadow:
238
- 0 4px 0 rgba(255, 255, 255, 0.9),
239
- 0 8px 0 rgba(255, 255, 255, 0.9);
240
- }
241
- }
242
-
243
- > .resize-handle-right {
244
- right: -4px;
245
- top: 50%;
246
- transform: translateY(-50%);
247
- width: 6px;
248
- height: 20px;
249
- cursor: ew-resize;
250
-
251
- &::before {
252
- width: 2px;
253
- height: 2px;
254
- top: 6px;
255
- left: 50%;
256
- transform: translateX(-50%);
257
- box-shadow:
258
- 0 4px 0 rgba(255, 255, 255, 0.9),
259
- 0 8px 0 rgba(255, 255, 255, 0.9);
260
- }
261
- }
262
-
263
- > .resize-handle-bottom {
264
- bottom: -4px;
265
- left: 50%;
266
- transform: translateX(-50%);
267
- width: 20px;
268
- height: 6px;
269
- cursor: ns-resize;
270
-
271
- &::before {
272
- width: 2px;
273
- height: 2px;
274
- left: 6px;
275
- top: 50%;
276
- transform: translateY(-50%);
277
- box-shadow:
278
- 4px 0 0 rgba(255, 255, 255, 0.9),
279
- 8px 0 0 rgba(255, 255, 255, 0.9);
280
- }
281
- }
282
- }
283
- `;
321
+ const buildToolbarContainerClassName = ({
322
+ showBackground,
323
+ showBorder,
324
+ ctx,
325
+ portalRenderSnapshot,
326
+ isToolbarVisible,
327
+ className,
328
+ }: {
329
+ showBackground: boolean;
330
+ showBorder: boolean;
331
+ ctx: any;
332
+ portalRenderSnapshot: ToolbarPortalRenderSnapshot | null;
333
+ isToolbarVisible: boolean;
334
+ className?: string;
335
+ }) =>
336
+ [
337
+ toolbarContainerStyles({ showBackground, showBorder, ctx }),
338
+ 'nb-toolbar-portal',
339
+ portalRenderSnapshot?.positioningMode === 'absolute' ? 'nb-toolbar-portal-absolute' : 'nb-toolbar-portal-fixed',
340
+ isToolbarVisible ? 'nb-toolbar-visible' : '',
341
+ className?.includes('nb-in-template') ? 'nb-in-template' : '',
342
+ ]
343
+ .filter(Boolean)
344
+ .join(' ');
345
+
346
+ const buildToolbarContainerStyle = (
347
+ portalRect: ToolbarPortalRect,
348
+ toolbarStyle?: React.CSSProperties,
349
+ ): React.CSSProperties => ({
350
+ top: `${portalRect.top}px`,
351
+ left: `${portalRect.left}px`,
352
+ width: `${portalRect.width}px`,
353
+ height: `${portalRect.height}px`,
354
+ ...omitToolbarPortalInsetStyle(toolbarStyle),
355
+ });
284
356
 
285
- // 悬浮右键菜单组件接口
286
- interface ModelProvidedProps {
357
+ interface ModelProvidedProps extends BaseFloatContextMenuProps {
287
358
  model: FlowModel<any>;
288
- children?: React.ReactNode;
289
- enabled?: boolean;
290
- showDeleteButton?: boolean;
291
- showCopyUidButton?: boolean;
292
- containerStyle?: React.CSSProperties;
293
- toolbarStyle?: React.CSSProperties;
294
- className?: string;
295
- /**
296
- * @default true
297
- */
298
- showBorder?: boolean;
299
- /**
300
- * @default true
301
- */
302
- showBackground?: boolean;
303
- /**
304
- * @default false
305
- */
306
- showTitle?: boolean;
307
- /**
308
- * @default false
309
- */
310
- showDragHandle?: boolean;
311
- /**
312
- * Settings menu levels: 1=current model only (default), 2=include sub-models
313
- */
314
- settingsMenuLevel?: number;
315
- /**
316
- * Extra toolbar items to add to this context menu instance
317
- */
318
- extraToolbarItems?: ToolbarItemConfig[];
319
- /**
320
- * @default 'inside'
321
- */
322
- toolbarPosition?: 'inside' | 'above' | 'below';
323
359
  }
324
360
 
325
- interface ModelByIdProps {
361
+ interface ModelByIdProps extends BaseFloatContextMenuProps {
326
362
  uid: string;
327
363
  modelClassName: string;
328
- children?: React.ReactNode;
329
- enabled?: boolean;
330
- showDeleteButton?: boolean;
331
- showCopyUidButton?: boolean;
332
- containerStyle?: React.CSSProperties;
333
- className?: string;
334
- /**
335
- * @default true
336
- */
337
- showBorder?: boolean;
338
- /**
339
- * @default true
340
- */
341
- showBackground?: boolean;
342
- /**
343
- * @default false
344
- */
345
- showTitle?: boolean;
346
- /**
347
- * Settings menu levels: 1=current model only (default), 2=include sub-models
348
- */
349
- settingsMenuLevel?: number;
350
- /**
351
- * Extra toolbar items to add to this context menu instance
352
- */
353
- extraToolbarItems?: ToolbarItemConfig[];
354
- /**
355
- * @default 'inside'
356
- */
357
- toolbarPosition?: 'inside' | 'above' | 'below';
358
364
  }
359
365
 
360
366
  type FlowsFloatContextMenuProps = ModelProvidedProps | ModelByIdProps;
361
367
 
362
- // 判断是否是通过ID获取模型的props
368
+ // 判断是否是通过ID获取模型的 props
363
369
  const isModelByIdProps = (props: FlowsFloatContextMenuProps): props is ModelByIdProps => {
364
370
  return 'uid' in props && 'modelClassName' in props && Boolean(props.uid) && Boolean(props.modelClassName);
365
371
  };
366
372
 
367
373
  /**
368
- * FlowsFloatContextMenu组件 - 悬浮配置图标组件
374
+ * FlowsFloatContextMenu组件 - 悬浮配置工具栏组件
369
375
  *
370
376
  * 功能特性:
371
377
  * - 鼠标悬浮显示右上角配置图标
372
378
  * - 点击图标显示配置菜单
373
379
  * - 支持删除功能
374
380
  * - Wrapper 模式支持
375
- * - 使用与 NocoBase x-settings 一致的样式
376
- * - 按flow分组显示steps
381
+ * - 使用 portal overlay 避免被宿主或祖先裁剪
382
+ * - 设置菜单与工具栏共享同一个 popup 容器
377
383
  *
378
384
  * 支持两种使用方式:
379
- * 1. 直接提供model: <FlowsFloatContextMenu model={myModel}>{children}</FlowsFloatContextMenu>
380
- * 2. 通过uid和modelClassName获取model: <FlowsFloatContextMenu uid="model1" modelClassName="MyModel">{children}</FlowsFloatContextMenu>
385
+ * 1. 直接提供 model: `<FlowsFloatContextMenu model={myModel}>{children}</FlowsFloatContextMenu>`
386
+ * 2. 通过 uid modelClassName 获取 model:
387
+ * `<FlowsFloatContextMenu uid="model1" modelClassName="MyModel">{children}</FlowsFloatContextMenu>`
381
388
  *
382
389
  * @param props.children 子组件,必须提供
383
- * @param props.enabled 是否启用悬浮菜单,默认为true
384
- * @param props.showDeleteButton 是否显示删除按钮,默认为true
385
- * @param props.showCopyUidButton 是否显示复制UID按钮,默认为true
390
+ * @param props.enabled 是否启用悬浮菜单,默认为 true
391
+ * @param props.showDeleteButton 是否显示删除按钮,默认为 true
392
+ * @param props.showCopyUidButton 是否显示复制 UID 按钮,默认为 true
386
393
  * @param props.containerStyle 容器自定义样式
394
+ * @param props.toolbarStyle 工具栏自定义样式;`top/left/right/bottom` 会作为 portal overlay 的 inset 使用
387
395
  * @param props.className 容器自定义类名
388
- * @param props.showTitle 是否在边框左上角显示模型title,默认为false
396
+ * @param props.showTitle 是否在边框左上角显示模型 title,默认为 false
389
397
  * @param props.settingsMenuLevel 设置菜单层级:1=仅当前模型(默认),2=包含子模型
390
398
  * @param props.extraToolbarItems 额外的工具栏项目,仅应用于此实例
391
399
  */
392
400
  const FlowsFloatContextMenu: React.FC<FlowsFloatContextMenuProps> = observer((props) => {
393
401
  const ctx = useFlowContext();
394
- // Only render if flowSettings is enabled
395
402
  if (!ctx.flowSettingsEnabled) {
396
403
  return <>{props.children}</>;
397
404
  }
398
405
  if (isModelByIdProps(props)) {
399
406
  return <FlowsFloatContextMenuWithModelById {...props} />;
400
- } else {
401
- return <FlowsFloatContextMenuWithModel {...props} />;
402
407
  }
408
+ return <FlowsFloatContextMenuWithModel {...props} />;
403
409
  });
404
410
 
405
- const ResizeHandles: React.FC<{ model: FlowModel; onDragStart: () => void; onDragEnd: () => void }> = (props) => {
411
+ const ResizeHandles: React.FC<{
412
+ model: FlowModel;
413
+ onDragStart: () => void;
414
+ onDragEnd: () => void;
415
+ onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
416
+ onMouseLeave?: React.MouseEventHandler<HTMLDivElement>;
417
+ }> = (props) => {
406
418
  const isDraggingRef = useRef<boolean>(false);
407
- const dragTypeRef = useRef<'left' | 'right' | 'bottom' | 'corner' | null>(null);
419
+ const dragTypeRef = useRef<'left' | 'right' | null>(null);
408
420
  const dragStartPosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
409
421
  const { onDragStart, onDragEnd } = props;
410
422
 
411
- // 拖拽移动处理函数
423
+ // 把拖拽位移转成上层已约定的 resize 事件。
412
424
  const handleDragMove = useCallback(
413
425
  (e: MouseEvent) => {
414
426
  if (!isDraggingRef.current || !dragTypeRef.current) return;
415
427
 
416
428
  const deltaX = e.clientX - dragStartPosRef.current.x;
417
- const deltaY = e.clientY - dragStartPosRef.current.y;
418
-
419
- let resizeDistance = 0;
420
429
 
421
430
  switch (dragTypeRef.current) {
422
431
  case 'left':
423
- // 左侧把手:向左拖为正数,向右拖为负数
424
- resizeDistance = -deltaX;
425
- props.model.parent.emitter.emit('onResizeLeft', { resizeDistance, model: props.model });
432
+ props.model.parent.emitter.emit('onResizeLeft', { resizeDistance: -deltaX, model: props.model });
426
433
  break;
427
-
428
434
  case 'right':
429
- // 右侧把手:向右拖为正数,向左拖为负数
430
- resizeDistance = deltaX;
431
- props.model.parent.emitter.emit('onResizeRight', { resizeDistance, model: props.model });
432
- break;
433
-
434
- case 'bottom':
435
- // 底部把手:向下拖为正数,向上拖为负数
436
- resizeDistance = deltaY;
437
- props.model.parent.emitter.emit('onResizeBottom', { resizeDistance, model: props.model });
438
- break;
439
-
440
- case 'corner': {
441
- // 右下角把手:同时计算宽度和高度变化
442
- const widthDelta = deltaX;
443
- const heightDelta = deltaY;
444
- props.model.parent.emitter.emit('onResizeCorner', { widthDelta, heightDelta, model: props.model });
435
+ props.model.parent.emitter.emit('onResizeRight', { resizeDistance: deltaX, model: props.model });
445
436
  break;
446
- }
447
437
  }
448
438
  },
449
439
  [props.model],
450
440
  );
451
441
 
452
- // 拖拽结束处理函数
453
442
  const handleDragEnd = useCallback(() => {
454
443
  isDraggingRef.current = false;
455
444
  dragTypeRef.current = null;
456
445
  dragStartPosRef.current = { x: 0, y: 0 };
457
446
 
458
- // 移除全局事件监听
459
447
  document.removeEventListener('mousemove', handleDragMove);
460
448
  document.removeEventListener('mouseup', handleDragEnd);
461
449
 
462
450
  props.model.parent.emitter.emit('onResizeEnd');
463
451
  onDragEnd?.();
464
- }, [handleDragMove, props.model, onDragEnd]);
452
+ }, [handleDragMove, onDragEnd, props.model]);
465
453
 
466
- // 拖拽开始处理函数
467
454
  const handleDragStart = useCallback(
468
- (e: React.MouseEvent, type: 'left' | 'right' | 'bottom' | 'corner') => {
455
+ (e: React.MouseEvent, type: 'left' | 'right') => {
469
456
  e.preventDefault();
470
457
  e.stopPropagation();
471
458
 
@@ -473,7 +460,6 @@ const ResizeHandles: React.FC<{ model: FlowModel; onDragStart: () => void; onDra
473
460
  dragTypeRef.current = type;
474
461
  dragStartPosRef.current = { x: e.clientX, y: e.clientY };
475
462
 
476
- // 添加全局事件监听
477
463
  document.addEventListener('mousemove', handleDragMove);
478
464
  document.addEventListener('mouseup', handleDragEnd);
479
465
 
@@ -484,27 +470,25 @@ const ResizeHandles: React.FC<{ model: FlowModel; onDragStart: () => void; onDra
484
470
 
485
471
  return (
486
472
  <>
487
- {/* 拖拽把手 */}
488
473
  <div
489
474
  className="resize-handle resize-handle-left"
490
475
  title="拖拽调节宽度"
476
+ onMouseEnter={props.onMouseEnter}
477
+ onMouseLeave={props.onMouseLeave}
491
478
  onMouseDown={(e) => handleDragStart(e, 'left')}
492
- ></div>
479
+ />
493
480
  <div
494
481
  className="resize-handle resize-handle-right"
495
482
  title="拖拽调节宽度"
483
+ onMouseEnter={props.onMouseEnter}
484
+ onMouseLeave={props.onMouseLeave}
496
485
  onMouseDown={(e) => handleDragStart(e, 'right')}
497
- ></div>
498
- {/* <div
499
- className="resize-handle resize-handle-bottom"
500
- title="拖拽调节高度"
501
- onMouseDown={(e) => handleDragStart(e, 'bottom')}
502
- ></div> */}
486
+ />
503
487
  </>
504
488
  );
505
489
  };
506
490
 
507
- // 使用传入的model
491
+ // 使用直接传入的 model 渲染工具栏。
508
492
  const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
509
493
  ({
510
494
  model,
@@ -523,34 +507,93 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
523
507
  toolbarStyle,
524
508
  toolbarPosition = 'inside',
525
509
  }: ModelProvidedProps) => {
526
- const [hideMenu, setHideMenu] = useState<boolean>(false);
527
- const [hasButton, setHasButton] = useState<boolean>(false);
510
+ const [hasButton, setHasButton] = useState(false);
528
511
  const containerRef = useRef<HTMLDivElement>(null);
529
- const flowEngine = useFlowEngine();
530
- const [style, setStyle] = useState<React.CSSProperties>({});
531
512
  const toolbarContainerRef = useRef<HTMLDivElement>(null);
532
- const toolbarContainerStyle: any = useMemo(() => ({ ...toolbarStyle, ...style }), [style, toolbarStyle]);
513
+ const portalActionsRef = useRef<{
514
+ updatePortalRect: () => void;
515
+ schedulePortalRectUpdate: () => void;
516
+ }>({
517
+ updatePortalRect: () => {},
518
+ schedulePortalRectUpdate: () => {},
519
+ });
520
+ const modelUid = model?.uid || '';
521
+ const flowEngine = useFlowEngine();
522
+ const updatePortalRectProxy = useCallback(() => {
523
+ portalActionsRef.current.updatePortalRect();
524
+ }, []);
525
+ const schedulePortalRectUpdateProxy = useCallback(() => {
526
+ portalActionsRef.current.schedulePortalRectUpdate();
527
+ }, []);
528
+ const {
529
+ isToolbarVisible,
530
+ shouldRenderToolbar,
531
+ handleSettingsMenuOpenChange,
532
+ handleChildHover,
533
+ handleHostMouseEnter,
534
+ handleHostMouseLeave,
535
+ handleToolbarMouseEnter,
536
+ handleToolbarMouseLeave,
537
+ handleResizeDragStart,
538
+ handleResizeDragEnd,
539
+ } = useFloatToolbarVisibility({
540
+ modelUid,
541
+ containerRef,
542
+ toolbarContainerRef,
543
+ updatePortalRect: updatePortalRectProxy,
544
+ schedulePortalRectUpdate: schedulePortalRectUpdateProxy,
545
+ });
546
+ const { portalRect, portalRenderSnapshot, getPopupContainer, updatePortalRect, schedulePortalRectUpdate } =
547
+ useFloatToolbarPortal({
548
+ active: shouldRenderToolbar,
549
+ containerRef,
550
+ toolbarContainerRef,
551
+ toolbarStyle,
552
+ });
553
+
554
+ portalActionsRef.current.updatePortalRect = updatePortalRect;
555
+ portalActionsRef.current.schedulePortalRectUpdate = schedulePortalRectUpdate;
556
+
557
+ const toolbarItems = useMemo(
558
+ () =>
559
+ model
560
+ ? renderToolbarItems(
561
+ model,
562
+ showDeleteButton,
563
+ showCopyUidButton,
564
+ flowEngine,
565
+ settingsMenuLevel,
566
+ extraToolbarItems,
567
+ handleSettingsMenuOpenChange,
568
+ getPopupContainer,
569
+ )
570
+ : [],
571
+ [
572
+ extraToolbarItems,
573
+ flowEngine,
574
+ getPopupContainer,
575
+ handleSettingsMenuOpenChange,
576
+ model,
577
+ settingsMenuLevel,
578
+ showCopyUidButton,
579
+ showDeleteButton,
580
+ ],
581
+ );
533
582
 
534
- // 检测DOM中是否包含button元素
535
583
  useEffect(() => {
536
- if (containerRef.current) {
537
- const hasButtonElement = detectButtonInDOM(containerRef.current);
538
- setHasButton(hasButtonElement);
584
+ const container = containerRef.current;
585
+ if (!container) {
586
+ return;
539
587
  }
540
- }, [children]); // 当children变化时重新检测
541
588
 
542
- // 使用MutationObserver监听DOM变化
543
- useEffect(() => {
544
- if (!containerRef.current) return;
589
+ const syncHasButton = () => {
590
+ setHasButton(detectButtonInDOM(container));
591
+ };
545
592
 
546
- const observer = new MutationObserver(() => {
547
- if (containerRef.current) {
548
- const hasButtonElement = detectButtonInDOM(containerRef.current);
549
- setHasButton(hasButtonElement);
550
- }
551
- });
593
+ syncHasButton();
552
594
 
553
- observer.observe(containerRef.current, {
595
+ const observer = new MutationObserver(syncHasButton);
596
+ observer.observe(container, {
554
597
  childList: true,
555
598
  subtree: true,
556
599
  attributes: true,
@@ -562,75 +605,79 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
562
605
  };
563
606
  }, []);
564
607
 
565
- const handleChildHover = useCallback((e: React.MouseEvent) => {
566
- const target = e.target as HTMLElement;
567
- const childWithMenu = target.closest('[data-has-float-menu]');
568
-
569
- // 如果悬浮的是子元素(且不是当前容器),则隐藏当前菜单
570
- if (childWithMenu && childWithMenu !== containerRef.current) {
571
- setHideMenu(true);
572
- } else {
573
- setHideMenu(false);
574
- }
575
- }, []);
576
-
577
608
  if (!model) {
578
609
  const t = getT(model || ({} as FlowModel));
579
610
  return <Alert message={t('Invalid model provided')} type="error" />;
580
611
  }
581
612
 
582
- // 如果未启用或没有children,直接返回children
583
613
  if (!enabled || !children) {
584
614
  return <>{children}</>;
585
615
  }
586
616
 
617
+ const toolbarContainerClassName = buildToolbarContainerClassName({
618
+ showBackground,
619
+ showBorder,
620
+ ctx: model.context,
621
+ portalRenderSnapshot,
622
+ isToolbarVisible,
623
+ className,
624
+ });
625
+ const toolbarContainerStyle = buildToolbarContainerStyle(portalRect, toolbarStyle);
626
+
627
+ const toolbarNode = shouldRenderToolbar ? (
628
+ <div
629
+ ref={toolbarContainerRef}
630
+ className={`nb-toolbar-container ${toolbarContainerClassName}`}
631
+ style={toolbarContainerStyle}
632
+ data-model-uid={model.uid}
633
+ >
634
+ {showTitle && (model.title || model.extraTitle) && (
635
+ <div className="nb-toolbar-container-title">
636
+ {model.title && <span className="title-tag">{model.title}</span>}
637
+ {model.extraTitle && <span className="title-tag">{model.extraTitle}</span>}
638
+ </div>
639
+ )}
640
+ <div
641
+ className={`nb-toolbar-container-icons ${toolbarPositionClassNames[toolbarPosition]}`}
642
+ onClick={(e) => e.stopPropagation()}
643
+ onMouseDown={(e) => e.stopPropagation()}
644
+ onMouseMove={(e) => e.stopPropagation()}
645
+ onMouseEnter={handleToolbarMouseEnter}
646
+ onMouseLeave={handleToolbarMouseLeave}
647
+ >
648
+ <Space size={3} align="center">
649
+ {toolbarItems}
650
+ </Space>
651
+ </div>
652
+
653
+ {showDragHandle && (
654
+ <ResizeHandles
655
+ model={model}
656
+ onMouseEnter={handleToolbarMouseEnter}
657
+ onMouseLeave={handleToolbarMouseLeave}
658
+ onDragStart={handleResizeDragStart}
659
+ onDragEnd={handleResizeDragEnd}
660
+ />
661
+ )}
662
+ </div>
663
+ ) : null;
664
+
587
665
  return (
588
666
  <div
589
667
  ref={containerRef}
590
- className={`${floatContainerStyles({
591
- showBackground,
592
- showBorder,
593
- ctx: model.context,
594
- toolbarPosition,
595
- toolbarCount: getToolbarCount(flowEngine, extraToolbarItems),
596
- })} ${hideMenu ? 'hide-parent-menu' : ''} ${hasButton ? 'has-button-child' : ''} ${className || ''}`}
668
+ className={`${hostContainerStyles} ${hasButton ? 'has-button-child' : ''} ${className || ''}`}
597
669
  style={containerStyle}
598
670
  data-has-float-menu="true"
671
+ data-float-menu-model-uid={model.uid}
599
672
  onMouseMove={handleChildHover}
673
+ onMouseEnter={handleHostMouseEnter}
674
+ onMouseLeave={handleHostMouseLeave}
600
675
  >
601
676
  {children}
602
-
603
- {/* 悬浮工具栏 - 使用与 NocoBase 一致的结构 */}
604
- <div ref={toolbarContainerRef} className="nb-toolbar-container" style={toolbarContainerStyle}>
605
- {showTitle && (model.title || model.extraTitle) && (
606
- <div className="nb-toolbar-container-title">
607
- {model.title && <span className="title-tag">{model.title}</span>}
608
- {model.extraTitle && <span className="title-tag">{model.extraTitle}</span>}
609
- </div>
610
- )}
611
- <div
612
- className="nb-toolbar-container-icons"
613
- onClick={(e) => e.stopPropagation()}
614
- onMouseDown={(e) => e.stopPropagation()}
615
- onMouseMove={(e) => e.stopPropagation()}
616
- >
617
- <Space size={3} align="center">
618
- {renderToolbarItems(
619
- model,
620
- showDeleteButton,
621
- showCopyUidButton,
622
- flowEngine,
623
- settingsMenuLevel,
624
- extraToolbarItems,
625
- )}
626
- </Space>
627
- </div>
628
-
629
- {/* 拖拽把手 */}
630
- {showDragHandle && (
631
- <ResizeHandles model={model} onDragStart={() => setStyle({ opacity: 1 })} onDragEnd={() => setStyle({})} />
632
- )}
633
- </div>
677
+ {toolbarNode &&
678
+ (portalRenderSnapshot?.mountElement
679
+ ? createPortal(toolbarNode, portalRenderSnapshot.mountElement)
680
+ : toolbarNode)}
634
681
  </div>
635
682
  );
636
683
  },
@@ -639,22 +686,9 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
639
686
  },
640
687
  );
641
688
 
642
- // 通过useModelById hook获取model
689
+ // 通过 uid + modelClassName 解析 model,再复用主实现。
643
690
  const FlowsFloatContextMenuWithModelById: React.FC<ModelByIdProps> = observer(
644
- ({
645
- uid,
646
- modelClassName,
647
- children,
648
- enabled = true,
649
- showDeleteButton = true,
650
- showCopyUidButton = true,
651
- containerStyle,
652
- className,
653
- showTitle = false,
654
- settingsMenuLevel,
655
- extraToolbarItems: extraToolbarItems,
656
- toolbarPosition,
657
- }) => {
691
+ ({ uid, modelClassName, children, ...restProps }) => {
658
692
  const model = useFlowModelById(uid, modelClassName);
659
693
  const flowEngine = useFlowEngine();
660
694
 
@@ -663,18 +697,7 @@ const FlowsFloatContextMenuWithModelById: React.FC<ModelByIdProps> = observer(
663
697
  }
664
698
 
665
699
  return (
666
- <FlowsFloatContextMenuWithModel
667
- model={model}
668
- enabled={enabled}
669
- showDeleteButton={showDeleteButton}
670
- showCopyUidButton={showCopyUidButton}
671
- containerStyle={containerStyle}
672
- className={className}
673
- showTitle={showTitle}
674
- settingsMenuLevel={settingsMenuLevel}
675
- extraToolbarItems={extraToolbarItems}
676
- toolbarPosition={toolbarPosition}
677
- >
700
+ <FlowsFloatContextMenuWithModel model={model} {...restProps}>
678
701
  {children}
679
702
  </FlowsFloatContextMenuWithModel>
680
703
  );
@@ -685,9 +708,3 @@ const FlowsFloatContextMenuWithModelById: React.FC<ModelByIdProps> = observer(
685
708
  );
686
709
 
687
710
  export { FlowsFloatContextMenu };
688
-
689
- function getToolbarCount(flowEngine, extraToolbarItems) {
690
- const toolbarItems = flowEngine?.flowSettings?.getToolbarItems?.() || [];
691
- const allToolbarItems = [...toolbarItems, ...(extraToolbarItems || [])];
692
- return allToolbarItems.length;
693
- }