@nocobase/flow-engine 2.1.0-alpha.13 → 2.1.0-alpha.15
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/FlowModelRenderer.d.ts +1 -1
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +48 -9
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +19 -43
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +332 -296
- package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
- package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +272 -0
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +247 -0
- package/lib/data-source/index.js +6 -0
- package/lib/flowContext.js +27 -0
- package/lib/flowEngine.d.ts +15 -3
- package/lib/flowEngine.js +62 -6
- package/lib/locale/en-US.json +1 -0
- package/lib/locale/index.d.ts +2 -0
- package/lib/locale/zh-CN.json +1 -0
- package/lib/reactive/observer.js +46 -16
- package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +7 -7
- package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +1 -1
- package/lib/types.d.ts +15 -16
- package/package.json +5 -4
- package/src/__tests__/flow-engine.test.ts +154 -36
- package/src/__tests__/flowContext.test.ts +65 -1
- package/src/__tests__/flowEngine.modelLoaders.test.ts +1 -5
- package/src/__tests__/flowEngine.saveModel.test.ts +0 -4
- package/src/__tests__/runjsSnippets.test.ts +2 -2
- package/src/components/FlowModelRenderer.tsx +3 -1
- package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +17 -7
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +63 -9
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +457 -440
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +95 -0
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +547 -0
- package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +358 -0
- package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +281 -0
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +0 -1
- package/src/data-source/index.ts +6 -0
- package/src/flowContext.ts +30 -0
- package/src/flowEngine.ts +79 -11
- package/src/locale/en-US.json +1 -0
- package/src/locale/zh-CN.json +1 -0
- package/src/reactive/__tests__/observer.test.tsx +82 -0
- package/src/reactive/observer.tsx +87 -25
- package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +7 -7
- package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +1 -1
- package/src/types.ts +17 -16
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
11
|
+
import type { CSSProperties, RefObject } from 'react';
|
|
12
|
+
|
|
13
|
+
const APP_CONTAINER_SELECTOR = '#nocobase-app-container';
|
|
14
|
+
const DRAWER_CONTENT_WRAPPER_SELECTOR = '.ant-drawer-content-wrapper';
|
|
15
|
+
const DRAWER_CONTENT_SELECTOR = '.ant-drawer-content';
|
|
16
|
+
const DRAWER_ROOT_SELECTOR = '.ant-drawer-root';
|
|
17
|
+
const MODAL_SELECTOR = '.ant-modal';
|
|
18
|
+
const MODAL_WRAP_SELECTOR = '.ant-modal-wrap';
|
|
19
|
+
const MODAL_ROOT_SELECTOR = '.ant-modal-root';
|
|
20
|
+
|
|
21
|
+
type ToolbarPortalPositioningMode = 'fixed' | 'absolute';
|
|
22
|
+
|
|
23
|
+
interface ToolbarPortalHostConfig {
|
|
24
|
+
mountElement: HTMLElement;
|
|
25
|
+
positioningElement: HTMLElement;
|
|
26
|
+
positioningMode: ToolbarPortalPositioningMode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ToolbarPortalRenderSnapshot {
|
|
30
|
+
mountElement: HTMLElement;
|
|
31
|
+
positioningMode: ToolbarPortalPositioningMode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ToolbarPortalRect {
|
|
35
|
+
top: number;
|
|
36
|
+
left: number;
|
|
37
|
+
width: number;
|
|
38
|
+
height: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ToolbarPortalInset {
|
|
42
|
+
top: number;
|
|
43
|
+
left: number;
|
|
44
|
+
right: number;
|
|
45
|
+
bottom: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface UseFloatToolbarPortalOptions {
|
|
49
|
+
active: boolean;
|
|
50
|
+
containerRef: RefObject<HTMLDivElement>;
|
|
51
|
+
toolbarContainerRef: RefObject<HTMLDivElement>;
|
|
52
|
+
toolbarStyle?: CSSProperties;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface UseFloatToolbarPortalResult {
|
|
56
|
+
portalRect: ToolbarPortalRect;
|
|
57
|
+
portalRenderSnapshot: ToolbarPortalRenderSnapshot | null;
|
|
58
|
+
getPopupContainer: (triggerNode?: HTMLElement) => HTMLElement;
|
|
59
|
+
updatePortalRect: () => void;
|
|
60
|
+
schedulePortalRectUpdate: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const defaultPortalRect: ToolbarPortalRect = {
|
|
64
|
+
top: 0,
|
|
65
|
+
left: 0,
|
|
66
|
+
width: 0,
|
|
67
|
+
height: 0,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const getClosestElement = (hostEl: HTMLElement | null, selector: string) =>
|
|
71
|
+
hostEl?.closest(selector) as HTMLElement | null;
|
|
72
|
+
|
|
73
|
+
const createAbsolutePortalHostConfig = (element: HTMLElement): ToolbarPortalHostConfig => ({
|
|
74
|
+
mountElement: element,
|
|
75
|
+
positioningElement: element,
|
|
76
|
+
positioningMode: 'absolute',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const popupPortalHostResolvers: Array<(hostEl: HTMLElement | null) => HTMLElement | null> = [
|
|
80
|
+
(hostEl) => getClosestElement(hostEl, DRAWER_CONTENT_WRAPPER_SELECTOR),
|
|
81
|
+
(hostEl) => getClosestElement(hostEl, MODAL_WRAP_SELECTOR),
|
|
82
|
+
(hostEl) => getClosestElement(hostEl, MODAL_SELECTOR),
|
|
83
|
+
(hostEl) => {
|
|
84
|
+
const drawerContent = getClosestElement(hostEl, DRAWER_CONTENT_SELECTOR);
|
|
85
|
+
return drawerContent ? getClosestElement(drawerContent, DRAWER_CONTENT_WRAPPER_SELECTOR) || drawerContent : null;
|
|
86
|
+
},
|
|
87
|
+
(hostEl) => getClosestElement(hostEl, DRAWER_ROOT_SELECTOR),
|
|
88
|
+
(hostEl) => getClosestElement(hostEl, MODAL_ROOT_SELECTOR),
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const getPopupPortalHostConfig = (hostEl: HTMLElement | null): ToolbarPortalHostConfig | null => {
|
|
92
|
+
for (const resolveHost of popupPortalHostResolvers) {
|
|
93
|
+
const popupHost = resolveHost(hostEl);
|
|
94
|
+
if (popupHost) {
|
|
95
|
+
return createAbsolutePortalHostConfig(popupHost);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const getToolbarPortalHostConfig = (hostEl: HTMLElement | null): ToolbarPortalHostConfig | null => {
|
|
103
|
+
if (typeof document === 'undefined') {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const popupRootConfig = getPopupPortalHostConfig(hostEl);
|
|
108
|
+
if (popupRootConfig) {
|
|
109
|
+
return popupRootConfig;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const appContainer = document.querySelector(APP_CONTAINER_SELECTOR) as HTMLElement | null;
|
|
113
|
+
if (appContainer) {
|
|
114
|
+
return createAbsolutePortalHostConfig(appContainer);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
mountElement: document.body,
|
|
119
|
+
positioningElement: document.body,
|
|
120
|
+
positioningMode: 'fixed',
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const parseToolbarInsetValue = (value: CSSProperties['top']) => {
|
|
125
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (typeof value === 'string') {
|
|
130
|
+
const trimmedValue = value.trim();
|
|
131
|
+
if (/^-?\d+(\.\d+)?(px)?$/.test(trimmedValue)) {
|
|
132
|
+
return Number.parseFloat(trimmedValue);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return 0;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const resolveToolbarPortalInset = (toolbarStyle?: CSSProperties): ToolbarPortalInset => {
|
|
140
|
+
return {
|
|
141
|
+
top: parseToolbarInsetValue(toolbarStyle?.top),
|
|
142
|
+
left: parseToolbarInsetValue(toolbarStyle?.left),
|
|
143
|
+
right: parseToolbarInsetValue(toolbarStyle?.right),
|
|
144
|
+
bottom: parseToolbarInsetValue(toolbarStyle?.bottom),
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const getAbsolutePositioningElement = (
|
|
149
|
+
toolbarEl: HTMLElement | null,
|
|
150
|
+
portalHostConfig: ToolbarPortalHostConfig | null,
|
|
151
|
+
) => {
|
|
152
|
+
if (!portalHostConfig || portalHostConfig.positioningMode !== 'absolute') {
|
|
153
|
+
return portalHostConfig?.positioningElement || null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const offsetParent = toolbarEl?.offsetParent;
|
|
157
|
+
if (
|
|
158
|
+
offsetParent instanceof HTMLElement &&
|
|
159
|
+
offsetParent !== document.body &&
|
|
160
|
+
offsetParent !== document.documentElement
|
|
161
|
+
) {
|
|
162
|
+
return offsetParent;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return portalHostConfig.positioningElement;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const calculatePortalRect = (
|
|
169
|
+
hostEl: HTMLElement | null,
|
|
170
|
+
portalHostConfig: ToolbarPortalHostConfig | null,
|
|
171
|
+
toolbarStyle?: CSSProperties,
|
|
172
|
+
toolbarEl?: HTMLElement | null,
|
|
173
|
+
): ToolbarPortalRect => {
|
|
174
|
+
if (!hostEl) {
|
|
175
|
+
return defaultPortalRect;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const inset = resolveToolbarPortalInset(toolbarStyle);
|
|
179
|
+
const hostRect = hostEl.getBoundingClientRect();
|
|
180
|
+
|
|
181
|
+
let rect: ToolbarPortalRect;
|
|
182
|
+
if (!portalHostConfig || portalHostConfig.positioningMode === 'fixed') {
|
|
183
|
+
rect = {
|
|
184
|
+
top: hostRect.top,
|
|
185
|
+
left: hostRect.left,
|
|
186
|
+
width: hostRect.width,
|
|
187
|
+
height: hostRect.height,
|
|
188
|
+
};
|
|
189
|
+
} else {
|
|
190
|
+
const positioningElement = getAbsolutePositioningElement(toolbarEl || null, portalHostConfig);
|
|
191
|
+
const portalHostRect =
|
|
192
|
+
positioningElement?.getBoundingClientRect() || portalHostConfig.positioningElement.getBoundingClientRect();
|
|
193
|
+
const scrollTop = positioningElement?.scrollTop ?? portalHostConfig.positioningElement.scrollTop;
|
|
194
|
+
const scrollLeft = positioningElement?.scrollLeft ?? portalHostConfig.positioningElement.scrollLeft;
|
|
195
|
+
|
|
196
|
+
rect = {
|
|
197
|
+
top: hostRect.top - portalHostRect.top + scrollTop,
|
|
198
|
+
left: hostRect.left - portalHostRect.left + scrollLeft,
|
|
199
|
+
width: hostRect.width,
|
|
200
|
+
height: hostRect.height,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
top: rect.top + inset.top,
|
|
206
|
+
left: rect.left + inset.left,
|
|
207
|
+
width: Math.max(0, rect.width - inset.left - inset.right),
|
|
208
|
+
height: Math.max(0, rect.height - inset.top - inset.bottom),
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
export const omitToolbarPortalInsetStyle = (toolbarStyle?: CSSProperties): CSSProperties | undefined => {
|
|
213
|
+
if (!toolbarStyle) {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const nextStyle = { ...toolbarStyle };
|
|
218
|
+
delete nextStyle.top;
|
|
219
|
+
delete nextStyle.left;
|
|
220
|
+
delete nextStyle.right;
|
|
221
|
+
delete nextStyle.bottom;
|
|
222
|
+
return nextStyle;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export const useFloatToolbarPortal = ({
|
|
226
|
+
active,
|
|
227
|
+
containerRef,
|
|
228
|
+
toolbarContainerRef,
|
|
229
|
+
toolbarStyle,
|
|
230
|
+
}: UseFloatToolbarPortalOptions): UseFloatToolbarPortalResult => {
|
|
231
|
+
const [portalRect, setPortalRect] = useState<ToolbarPortalRect>(defaultPortalRect);
|
|
232
|
+
const [portalRenderSnapshot, setPortalRenderSnapshot] = useState<ToolbarPortalRenderSnapshot | null>(null);
|
|
233
|
+
const portalHostConfigRef = useRef<ToolbarPortalHostConfig | null>(null);
|
|
234
|
+
const portalRafIdRef = useRef<number | null>(null);
|
|
235
|
+
|
|
236
|
+
const updatePortalRect = useCallback(() => {
|
|
237
|
+
const hostElement = containerRef.current;
|
|
238
|
+
if (!hostElement) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const nextPortalHostConfig = getToolbarPortalHostConfig(hostElement);
|
|
243
|
+
portalHostConfigRef.current = nextPortalHostConfig;
|
|
244
|
+
setPortalRenderSnapshot((prevSnapshot) => {
|
|
245
|
+
if (!nextPortalHostConfig) {
|
|
246
|
+
return prevSnapshot === null ? prevSnapshot : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (
|
|
250
|
+
prevSnapshot?.mountElement === nextPortalHostConfig.mountElement &&
|
|
251
|
+
prevSnapshot?.positioningMode === nextPortalHostConfig.positioningMode
|
|
252
|
+
) {
|
|
253
|
+
return prevSnapshot;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
mountElement: nextPortalHostConfig.mountElement,
|
|
258
|
+
positioningMode: nextPortalHostConfig.positioningMode,
|
|
259
|
+
};
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const nextRect = calculatePortalRect(hostElement, nextPortalHostConfig, toolbarStyle, toolbarContainerRef.current);
|
|
263
|
+
setPortalRect((prevRect) => {
|
|
264
|
+
if (
|
|
265
|
+
prevRect.top === nextRect.top &&
|
|
266
|
+
prevRect.left === nextRect.left &&
|
|
267
|
+
prevRect.width === nextRect.width &&
|
|
268
|
+
prevRect.height === nextRect.height
|
|
269
|
+
) {
|
|
270
|
+
return prevRect;
|
|
271
|
+
}
|
|
272
|
+
return nextRect;
|
|
273
|
+
});
|
|
274
|
+
}, [containerRef, toolbarContainerRef, toolbarStyle]);
|
|
275
|
+
|
|
276
|
+
const schedulePortalRectUpdate = useCallback(() => {
|
|
277
|
+
if (portalRafIdRef.current !== null) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
portalRafIdRef.current = window.requestAnimationFrame(() => {
|
|
282
|
+
portalRafIdRef.current = null;
|
|
283
|
+
updatePortalRect();
|
|
284
|
+
});
|
|
285
|
+
}, [updatePortalRect]);
|
|
286
|
+
|
|
287
|
+
const getPopupContainer = useCallback(
|
|
288
|
+
(triggerNode?: HTMLElement) => {
|
|
289
|
+
const fallbackContainer =
|
|
290
|
+
triggerNode?.ownerDocument?.body ||
|
|
291
|
+
containerRef.current?.ownerDocument?.body ||
|
|
292
|
+
(typeof document !== 'undefined' ? document.body : null);
|
|
293
|
+
|
|
294
|
+
return (portalHostConfigRef.current?.mountElement ||
|
|
295
|
+
getToolbarPortalHostConfig(triggerNode || containerRef.current)?.mountElement ||
|
|
296
|
+
fallbackContainer) as HTMLElement;
|
|
297
|
+
},
|
|
298
|
+
[containerRef],
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (!active) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
updatePortalRect();
|
|
307
|
+
|
|
308
|
+
const handleViewportChange = () => {
|
|
309
|
+
schedulePortalRectUpdate();
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const container = containerRef.current;
|
|
313
|
+
const mountElement = portalHostConfigRef.current?.mountElement;
|
|
314
|
+
const positioningElement = portalHostConfigRef.current?.positioningElement;
|
|
315
|
+
const resizeObserver =
|
|
316
|
+
typeof ResizeObserver !== 'undefined' && container
|
|
317
|
+
? new ResizeObserver(() => {
|
|
318
|
+
schedulePortalRectUpdate();
|
|
319
|
+
})
|
|
320
|
+
: null;
|
|
321
|
+
|
|
322
|
+
if (container) {
|
|
323
|
+
resizeObserver?.observe(container);
|
|
324
|
+
}
|
|
325
|
+
if (mountElement && mountElement !== container) {
|
|
326
|
+
resizeObserver?.observe(mountElement);
|
|
327
|
+
}
|
|
328
|
+
if (positioningElement && positioningElement !== container && positioningElement !== mountElement) {
|
|
329
|
+
resizeObserver?.observe(positioningElement);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
window.addEventListener('resize', handleViewportChange);
|
|
333
|
+
window.addEventListener('scroll', handleViewportChange, true);
|
|
334
|
+
|
|
335
|
+
return () => {
|
|
336
|
+
resizeObserver?.disconnect();
|
|
337
|
+
window.removeEventListener('resize', handleViewportChange);
|
|
338
|
+
window.removeEventListener('scroll', handleViewportChange, true);
|
|
339
|
+
};
|
|
340
|
+
}, [active, containerRef, schedulePortalRectUpdate, updatePortalRect]);
|
|
341
|
+
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
return () => {
|
|
344
|
+
if (portalRafIdRef.current !== null) {
|
|
345
|
+
window.cancelAnimationFrame(portalRafIdRef.current);
|
|
346
|
+
}
|
|
347
|
+
portalHostConfigRef.current = null;
|
|
348
|
+
};
|
|
349
|
+
}, []);
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
portalRect,
|
|
353
|
+
portalRenderSnapshot,
|
|
354
|
+
getPopupContainer,
|
|
355
|
+
updatePortalRect,
|
|
356
|
+
schedulePortalRectUpdate,
|
|
357
|
+
};
|
|
358
|
+
};
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
11
|
+
import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
|
|
12
|
+
|
|
13
|
+
const TOOLBAR_HIDE_DELAY = 180;
|
|
14
|
+
const CHILD_FLOAT_MENU_ACTIVITY_EVENT = 'nb-float-menu-child-activity';
|
|
15
|
+
|
|
16
|
+
interface UseFloatToolbarVisibilityOptions {
|
|
17
|
+
modelUid: string;
|
|
18
|
+
containerRef: RefObject<HTMLDivElement>;
|
|
19
|
+
toolbarContainerRef: RefObject<HTMLDivElement>;
|
|
20
|
+
updatePortalRect: () => void;
|
|
21
|
+
schedulePortalRectUpdate: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface UseFloatToolbarVisibilityResult {
|
|
25
|
+
isToolbarVisible: boolean;
|
|
26
|
+
shouldRenderToolbar: boolean;
|
|
27
|
+
handleSettingsMenuOpenChange: (open: boolean) => void;
|
|
28
|
+
handleChildHover: (e: ReactMouseEvent) => void;
|
|
29
|
+
handleHostMouseEnter: () => void;
|
|
30
|
+
handleHostMouseLeave: (e: ReactMouseEvent<HTMLDivElement>) => void;
|
|
31
|
+
handleToolbarMouseEnter: () => void;
|
|
32
|
+
handleToolbarMouseLeave: (e: ReactMouseEvent<HTMLDivElement>) => void;
|
|
33
|
+
handleResizeDragStart: () => void;
|
|
34
|
+
handleResizeDragEnd: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const isNodeWithin = (target: EventTarget | null, container: HTMLElement | null): boolean => {
|
|
38
|
+
return target instanceof Node && !!container?.contains(target);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const getToolbarModelUidFromTarget = (target: EventTarget | null): string | null => {
|
|
42
|
+
if (!(target instanceof Element)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return target.closest('.nb-toolbar-container[data-model-uid]')?.getAttribute('data-model-uid') || null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const isNodeWithinDescendantFloatToolbar = (
|
|
50
|
+
target: EventTarget | null,
|
|
51
|
+
container: HTMLElement | null,
|
|
52
|
+
currentModelUid: string,
|
|
53
|
+
): boolean => {
|
|
54
|
+
const targetModelUid = getToolbarModelUidFromTarget(target);
|
|
55
|
+
if (!container || !targetModelUid || targetModelUid === currentModelUid) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return Array.from(
|
|
60
|
+
container.querySelectorAll<HTMLElement>('[data-has-float-menu="true"][data-float-menu-model-uid]'),
|
|
61
|
+
).some(
|
|
62
|
+
(hostElement) =>
|
|
63
|
+
hostElement !== container && hostElement.getAttribute('data-float-menu-model-uid') === targetModelUid,
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const useFloatToolbarVisibility = ({
|
|
68
|
+
modelUid,
|
|
69
|
+
containerRef,
|
|
70
|
+
toolbarContainerRef,
|
|
71
|
+
updatePortalRect,
|
|
72
|
+
schedulePortalRectUpdate,
|
|
73
|
+
}: UseFloatToolbarVisibilityOptions): UseFloatToolbarVisibilityResult => {
|
|
74
|
+
const [hideMenu, setHideMenu] = useState(false);
|
|
75
|
+
const [isHostHovered, setIsHostHovered] = useState(false);
|
|
76
|
+
const [isToolbarHovered, setIsToolbarHovered] = useState(false);
|
|
77
|
+
const [isDraggingToolbar, setIsDraggingToolbar] = useState(false);
|
|
78
|
+
const [isToolbarPinned, setIsToolbarPinned] = useState(false);
|
|
79
|
+
const [isHidePending, setIsHidePending] = useState(false);
|
|
80
|
+
const [activeChildToolbarIds, setActiveChildToolbarIds] = useState<string[]>([]);
|
|
81
|
+
const hideToolbarTimerRef = useRef<number | null>(null);
|
|
82
|
+
const reportedChildActivityToAncestorsRef = useRef(false);
|
|
83
|
+
|
|
84
|
+
const hasActiveChildToolbar = activeChildToolbarIds.length > 0;
|
|
85
|
+
const isToolbarVisible =
|
|
86
|
+
!hideMenu && !hasActiveChildToolbar && (isHostHovered || isToolbarHovered || isDraggingToolbar || isToolbarPinned);
|
|
87
|
+
const shouldRenderToolbar = isToolbarVisible || isToolbarPinned || isDraggingToolbar;
|
|
88
|
+
const isToolbarInteractionActive =
|
|
89
|
+
isHostHovered || isToolbarHovered || isDraggingToolbar || isToolbarPinned || isHidePending;
|
|
90
|
+
|
|
91
|
+
const clearHideToolbarTimer = useCallback(() => {
|
|
92
|
+
if (hideToolbarTimerRef.current !== null) {
|
|
93
|
+
window.clearTimeout(hideToolbarTimerRef.current);
|
|
94
|
+
hideToolbarTimerRef.current = null;
|
|
95
|
+
}
|
|
96
|
+
setIsHidePending(false);
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
const scheduleHideToolbar = useCallback(() => {
|
|
100
|
+
clearHideToolbarTimer();
|
|
101
|
+
setIsHidePending(true);
|
|
102
|
+
hideToolbarTimerRef.current = window.setTimeout(() => {
|
|
103
|
+
hideToolbarTimerRef.current = null;
|
|
104
|
+
setIsHidePending(false);
|
|
105
|
+
if (isDraggingToolbar || isToolbarPinned) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
setIsHostHovered(false);
|
|
109
|
+
setIsToolbarHovered(false);
|
|
110
|
+
}, TOOLBAR_HIDE_DELAY);
|
|
111
|
+
}, [clearHideToolbarTimer, isDraggingToolbar, isToolbarPinned]);
|
|
112
|
+
|
|
113
|
+
const handleSettingsMenuOpenChange = useCallback((open: boolean) => {
|
|
114
|
+
setIsToolbarPinned(open);
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const hostElement = containerRef.current;
|
|
119
|
+
if (!hostElement) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const handleChildToolbarActivity = (event: Event) => {
|
|
124
|
+
const customEvent = event as CustomEvent<{ active?: boolean; modelUid?: string }>;
|
|
125
|
+
if (!(customEvent.target instanceof HTMLElement) || customEvent.target === hostElement) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const childModelUid = customEvent.detail?.modelUid;
|
|
130
|
+
if (!childModelUid) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setActiveChildToolbarIds((prevIds) => {
|
|
135
|
+
return customEvent.detail?.active
|
|
136
|
+
? prevIds.includes(childModelUid)
|
|
137
|
+
? prevIds
|
|
138
|
+
: [...prevIds, childModelUid]
|
|
139
|
+
: prevIds.filter((id) => id !== childModelUid);
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
hostElement.addEventListener(CHILD_FLOAT_MENU_ACTIVITY_EVENT, handleChildToolbarActivity as EventListener);
|
|
144
|
+
return () => {
|
|
145
|
+
hostElement.removeEventListener(CHILD_FLOAT_MENU_ACTIVITY_EVENT, handleChildToolbarActivity as EventListener);
|
|
146
|
+
};
|
|
147
|
+
}, [containerRef]);
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const hostElement = containerRef.current;
|
|
151
|
+
if (!hostElement || reportedChildActivityToAncestorsRef.current === isToolbarInteractionActive) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
reportedChildActivityToAncestorsRef.current = isToolbarInteractionActive;
|
|
156
|
+
hostElement.dispatchEvent(
|
|
157
|
+
new CustomEvent(CHILD_FLOAT_MENU_ACTIVITY_EVENT, {
|
|
158
|
+
bubbles: true,
|
|
159
|
+
detail: { active: isToolbarInteractionActive, modelUid },
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
}, [containerRef, isToolbarInteractionActive, modelUid]);
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
const hostElement = containerRef.current;
|
|
166
|
+
|
|
167
|
+
return () => {
|
|
168
|
+
if (hostElement && reportedChildActivityToAncestorsRef.current) {
|
|
169
|
+
hostElement.dispatchEvent(
|
|
170
|
+
new CustomEvent(CHILD_FLOAT_MENU_ACTIVITY_EVENT, {
|
|
171
|
+
bubbles: true,
|
|
172
|
+
detail: { active: false, modelUid },
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
reportedChildActivityToAncestorsRef.current = false;
|
|
176
|
+
}
|
|
177
|
+
clearHideToolbarTimer();
|
|
178
|
+
};
|
|
179
|
+
}, [clearHideToolbarTimer, containerRef, modelUid]);
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (isToolbarPinned) {
|
|
183
|
+
clearHideToolbarTimer();
|
|
184
|
+
updatePortalRect();
|
|
185
|
+
}
|
|
186
|
+
}, [clearHideToolbarTimer, isToolbarPinned, updatePortalRect]);
|
|
187
|
+
|
|
188
|
+
const handleChildHover = useCallback(
|
|
189
|
+
(e: ReactMouseEvent) => {
|
|
190
|
+
const target = e.target as HTMLElement;
|
|
191
|
+
const childWithMenu = target.closest('[data-has-float-menu]');
|
|
192
|
+
const isCurrentHostTarget = !childWithMenu || childWithMenu === containerRef.current;
|
|
193
|
+
|
|
194
|
+
if (isCurrentHostTarget) {
|
|
195
|
+
clearHideToolbarTimer();
|
|
196
|
+
setIsHostHovered(true);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
setHideMenu(!!childWithMenu && childWithMenu !== containerRef.current);
|
|
200
|
+
},
|
|
201
|
+
[clearHideToolbarTimer, containerRef],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const handleHostMouseEnter = useCallback(() => {
|
|
205
|
+
clearHideToolbarTimer();
|
|
206
|
+
setHideMenu(false);
|
|
207
|
+
updatePortalRect();
|
|
208
|
+
setIsHostHovered(true);
|
|
209
|
+
}, [clearHideToolbarTimer, updatePortalRect]);
|
|
210
|
+
|
|
211
|
+
const handleHostMouseLeave = useCallback(
|
|
212
|
+
(e: ReactMouseEvent<HTMLDivElement>) => {
|
|
213
|
+
if (isToolbarPinned) {
|
|
214
|
+
setIsHostHovered(false);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (isNodeWithin(e.relatedTarget, toolbarContainerRef.current)) {
|
|
218
|
+
clearHideToolbarTimer();
|
|
219
|
+
setIsHostHovered(false);
|
|
220
|
+
setIsToolbarHovered(true);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (isNodeWithinDescendantFloatToolbar(e.relatedTarget, containerRef.current, modelUid)) {
|
|
224
|
+
clearHideToolbarTimer();
|
|
225
|
+
setHideMenu(false);
|
|
226
|
+
setIsHostHovered(true);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
scheduleHideToolbar();
|
|
230
|
+
},
|
|
231
|
+
[clearHideToolbarTimer, containerRef, isToolbarPinned, modelUid, scheduleHideToolbar, toolbarContainerRef],
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const handleToolbarMouseEnter = useCallback(() => {
|
|
235
|
+
clearHideToolbarTimer();
|
|
236
|
+
updatePortalRect();
|
|
237
|
+
setIsHostHovered(false);
|
|
238
|
+
setIsToolbarHovered(true);
|
|
239
|
+
}, [clearHideToolbarTimer, updatePortalRect]);
|
|
240
|
+
|
|
241
|
+
const handleToolbarMouseLeave = useCallback(
|
|
242
|
+
(e: ReactMouseEvent<HTMLDivElement>) => {
|
|
243
|
+
if (isToolbarPinned) {
|
|
244
|
+
setIsToolbarHovered(false);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
setIsToolbarHovered(false);
|
|
248
|
+
if (isNodeWithin(e.relatedTarget, containerRef.current)) {
|
|
249
|
+
clearHideToolbarTimer();
|
|
250
|
+
setIsHostHovered(true);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
scheduleHideToolbar();
|
|
254
|
+
},
|
|
255
|
+
[clearHideToolbarTimer, containerRef, isToolbarPinned, scheduleHideToolbar],
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const handleResizeDragStart = useCallback(() => {
|
|
259
|
+
updatePortalRect();
|
|
260
|
+
setIsDraggingToolbar(true);
|
|
261
|
+
schedulePortalRectUpdate();
|
|
262
|
+
}, [schedulePortalRectUpdate, updatePortalRect]);
|
|
263
|
+
|
|
264
|
+
const handleResizeDragEnd = useCallback(() => {
|
|
265
|
+
setIsDraggingToolbar(false);
|
|
266
|
+
schedulePortalRectUpdate();
|
|
267
|
+
}, [schedulePortalRectUpdate]);
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
isToolbarVisible,
|
|
271
|
+
shouldRenderToolbar,
|
|
272
|
+
handleSettingsMenuOpenChange,
|
|
273
|
+
handleChildHover,
|
|
274
|
+
handleHostMouseEnter,
|
|
275
|
+
handleHostMouseLeave,
|
|
276
|
+
handleToolbarMouseEnter,
|
|
277
|
+
handleToolbarMouseLeave,
|
|
278
|
+
handleResizeDragStart,
|
|
279
|
+
handleResizeDragEnd,
|
|
280
|
+
};
|
|
281
|
+
};
|
|
@@ -1117,7 +1117,6 @@ describe('AddSubModelButton toggleable behavior', () => {
|
|
|
1117
1117
|
// Minimal fake repository for save/destroy
|
|
1118
1118
|
class FakeRepo implements IFlowModelRepository<any> {
|
|
1119
1119
|
findOne = vi.fn().mockResolvedValue(null);
|
|
1120
|
-
ensure = vi.fn(async (values: any) => await this.findOne(values));
|
|
1121
1120
|
save = vi.fn().mockResolvedValue({});
|
|
1122
1121
|
destroy = vi.fn().mockResolvedValue(true);
|
|
1123
1122
|
move = vi.fn().mockResolvedValue(undefined);
|
package/src/data-source/index.ts
CHANGED
|
@@ -501,6 +501,12 @@ export class Collection {
|
|
|
501
501
|
|
|
502
502
|
get titleCollectionField() {
|
|
503
503
|
const titleFieldName = this.options.titleField || this.filterTargetKey;
|
|
504
|
+
if (Array.isArray(titleFieldName)) {
|
|
505
|
+
if (titleFieldName.length !== 1) {
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
return this.getField(titleFieldName[0]);
|
|
509
|
+
}
|
|
504
510
|
const titleCollectionField = this.getField(titleFieldName);
|
|
505
511
|
return titleCollectionField;
|
|
506
512
|
}
|
package/src/flowContext.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { ISchema } from '@formily/json-schema';
|
|
|
11
11
|
import { observable } from '@formily/reactive';
|
|
12
12
|
import { APIClient, RequestOptions } from '@nocobase/sdk';
|
|
13
13
|
import type { Router } from '@remix-run/router';
|
|
14
|
+
import axios from 'axios';
|
|
14
15
|
import { MessageInstance } from 'antd/es/message/interface';
|
|
15
16
|
import * as antd from 'antd';
|
|
16
17
|
import type { HookAPI } from 'antd/es/modal/useModal';
|
|
@@ -58,6 +59,31 @@ import dayjs from 'dayjs';
|
|
|
58
59
|
import { externalReactRender, setupRunJSLibs } from './runjsLibs';
|
|
59
60
|
import { runjsImportAsync, runjsImportModule, runjsRequireAsync } from './utils/runjsModuleLoader';
|
|
60
61
|
|
|
62
|
+
function normalizePathname(pathname: string) {
|
|
63
|
+
return pathname.endsWith('/') ? pathname : `${pathname}/`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function shouldBypassApiClient(url: string, app?: { getApiUrl?: (pathname?: string) => string }) {
|
|
67
|
+
try {
|
|
68
|
+
const requestUrl = new URL(url);
|
|
69
|
+
if (!['http:', 'https:'].includes(requestUrl.protocol)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!app?.getApiUrl) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const apiUrl = new URL(app.getApiUrl());
|
|
78
|
+
const apiPath = normalizePathname(apiUrl.pathname);
|
|
79
|
+
const requestPath = normalizePathname(requestUrl.pathname);
|
|
80
|
+
|
|
81
|
+
return requestUrl.origin !== apiUrl.origin || !requestPath.startsWith(apiPath);
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
61
87
|
// Helper: detect a RecordRef-like object
|
|
62
88
|
function isRecordRefLike(val: any): boolean {
|
|
63
89
|
return !!(val && typeof val === 'object' && 'collection' in val && 'filterByTk' in val);
|
|
@@ -3024,6 +3050,10 @@ class BaseFlowEngineContext extends FlowContext {
|
|
|
3024
3050
|
return this.engine.getModel(modelName, searchInPreviousEngines);
|
|
3025
3051
|
});
|
|
3026
3052
|
this.defineMethod('request', (options: RequestOptions) => {
|
|
3053
|
+
const app = this.app as { getApiUrl?: (pathname?: string) => string } | undefined;
|
|
3054
|
+
if (typeof options?.url === 'string' && shouldBypassApiClient(options.url, app)) {
|
|
3055
|
+
return axios.request(options);
|
|
3056
|
+
}
|
|
3027
3057
|
return this.api.request(options);
|
|
3028
3058
|
});
|
|
3029
3059
|
this.defineMethod(
|