@nocobase/flow-engine 2.2.0-alpha.1 → 2.2.0-alpha.2
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/JSRunner.d.ts +1 -0
- package/lib/JSRunner.js +110 -20
- package/lib/components/FieldModelRenderer.js +44 -31
- package/lib/components/FlowContextSelector.js +24 -3
- 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/components/variables/VariableHybridInput.d.ts +8 -0
- package/lib/components/variables/VariableHybridInput.js +128 -12
- package/lib/components/variables/types.d.ts +8 -0
- package/lib/flowContext.d.ts +1 -1
- package/lib/flowContext.js +87 -36
- package/lib/flowI18n.js +3 -3
- package/lib/flowSettings.d.ts +5 -1
- package/lib/flowSettings.js +70 -0
- 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/resources/apiResource.js +2 -1
- package/lib/resources/baseRecordResource.js +6 -17
- package/lib/resources/multiRecordResource.js +13 -3
- package/lib/resources/singleRecordResource.js +7 -2
- package/lib/runjs-context/helpers.js +12 -5
- package/lib/types.d.ts +12 -0
- package/lib/utils/dataSourceDirty.d.ts +20 -0
- package/lib/utils/dataSourceDirty.js +139 -0
- package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
- package/lib/utils/dirtyAwareApiClient.js +378 -0
- package/lib/utils/index.d.ts +1 -1
- package/lib/utils/index.js +11 -11
- package/lib/utils/openViewRouteState.d.ts +28 -0
- package/lib/utils/openViewRouteState.js +125 -0
- package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
- package/lib/utils/parsePathnameToViewParams.js +18 -1
- package/lib/utils/resolveRunJSObjectValues.js +3 -2
- package/lib/utils/runjsModuleLoader.js +0 -30
- package/lib/views/ViewNavigation.js +5 -0
- package/package.json +4 -4
- package/src/JSRunner.ts +112 -25
- package/src/__tests__/JSRunner.test.ts +4 -5
- package/src/__tests__/flowContext.test.ts +131 -0
- package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
- package/src/__tests__/flowI18n.test.ts +11 -0
- package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
- package/src/__tests__/flowSettings.test.ts +72 -0
- package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
- package/src/components/FieldModelRenderer.tsx +50 -36
- package/src/components/FlowContextSelector.tsx +33 -2
- package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -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/components/variables/VariableHybridInput.tsx +166 -9
- package/src/components/variables/__tests__/VariableHybridInput.test.tsx +178 -0
- package/src/components/variables/types.ts +8 -0
- package/src/flowContext.ts +100 -33
- package/src/flowI18n.ts +8 -3
- package/src/flowSettings.ts +85 -1
- package/src/locale/en-US.json +1 -0
- package/src/locale/zh-CN.json +1 -0
- package/src/resources/apiResource.ts +2 -1
- package/src/resources/baseRecordResource.ts +6 -23
- package/src/resources/multiRecordResource.ts +13 -3
- package/src/resources/singleRecordResource.ts +6 -1
- package/src/runjs-context/helpers.ts +12 -6
- package/src/types.ts +14 -0
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +392 -0
- package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
- package/src/utils/dataSourceDirty.ts +126 -0
- package/src/utils/dirtyAwareApiClient.ts +430 -0
- package/src/utils/index.ts +10 -9
- package/src/utils/openViewRouteState.ts +107 -0
- package/src/utils/parsePathnameToViewParams.ts +23 -1
- package/src/utils/resolveRunJSObjectValues.ts +5 -2
- package/src/utils/runjsModuleLoader.ts +0 -32
- package/src/views/ViewNavigation.ts +6 -1
- package/src/views/__tests__/ViewNavigation.test.ts +15 -0
- package/lib/utils/safeGlobals.d.ts +0 -28
- package/lib/utils/safeGlobals.js +0 -367
- package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
- package/src/utils/__tests__/safeGlobals.test.ts +0 -106
- package/src/utils/safeGlobals.ts +0 -406
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
11
|
-
import { FlowEngineContext, FlowRunJSContext } from '../flowContext';
|
|
11
|
+
import { FlowContext, FlowEngineContext, FlowRunJSContext } from '../flowContext';
|
|
12
|
+
import { RUNJS_OPEN_VIEW_ROUTE_STATE } from '../utils/openViewRouteState';
|
|
12
13
|
import { FlowEngine } from '../flowEngine';
|
|
13
|
-
import { FlowContext } from '../flowContext';
|
|
14
14
|
import { setupRunJSContexts } from '../runjs-context/setup';
|
|
15
15
|
import { createJSRunnerWithVersion } from '..';
|
|
16
16
|
import { RunJSContextRegistry } from '../runjs-context/registry';
|
|
@@ -267,6 +267,19 @@ describe('RunJS Runtime Features', () => {
|
|
|
267
267
|
expect(runCtx.libs.dayjs).toBeDefined();
|
|
268
268
|
expect(runCtx.libs.antdIcons).toBeDefined();
|
|
269
269
|
});
|
|
270
|
+
|
|
271
|
+
it('should mark ctx.openView calls with route state only when RunJS passes display overrides', async () => {
|
|
272
|
+
const parentCtx = new FlowContext();
|
|
273
|
+
const openView = vi.fn(async () => undefined);
|
|
274
|
+
parentCtx.defineMethod('openView', openView);
|
|
275
|
+
|
|
276
|
+
const runCtx = new FlowRunJSContext(parentCtx);
|
|
277
|
+
await (runCtx as any).openView('popup', { mode: 'dialog', size: 'large' });
|
|
278
|
+
await (runCtx as any).openView('popup', { filterByTk: 1 });
|
|
279
|
+
|
|
280
|
+
expect(openView.mock.calls[0][1][RUNJS_OPEN_VIEW_ROUTE_STATE]).toEqual({ mode: 'dialog', size: 'large' });
|
|
281
|
+
expect(Object.prototype.hasOwnProperty.call(openView.mock.calls[1][1], RUNJS_OPEN_VIEW_ROUTE_STATE)).toBe(false);
|
|
282
|
+
});
|
|
270
283
|
});
|
|
271
284
|
|
|
272
285
|
describe('Actual code execution', () => {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import type { FlowModelRendererProps } from './FlowModelRenderer';
|
|
11
11
|
import { FlowModelRenderer } from './FlowModelRenderer';
|
|
12
12
|
import _ from 'lodash';
|
|
13
|
-
import React, { useEffect, useMemo, useRef } from 'react';
|
|
13
|
+
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
14
14
|
|
|
15
15
|
const flowModelRendererPropKeys: (keyof FlowModelRendererProps)[] = [
|
|
16
16
|
'model',
|
|
@@ -28,49 +28,63 @@ const flowModelRendererPropKeys: (keyof FlowModelRendererProps)[] = [
|
|
|
28
28
|
];
|
|
29
29
|
|
|
30
30
|
export function FieldModelRenderer(props: any) {
|
|
31
|
-
const { model, ...rest } = props;
|
|
31
|
+
const { model, onChange, ...rest } = props;
|
|
32
32
|
const composingRef = useRef(false);
|
|
33
33
|
|
|
34
|
-
const handleChange = (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
34
|
+
const handleChange = useCallback(
|
|
35
|
+
(e: any) => {
|
|
36
|
+
let val;
|
|
37
|
+
if (e && e.target && typeof e.target.value !== 'undefined') {
|
|
38
|
+
val = e.target.value;
|
|
39
|
+
} else if (
|
|
40
|
+
typeof e === 'string' ||
|
|
41
|
+
typeof e === 'number' ||
|
|
42
|
+
typeof e === 'boolean' ||
|
|
43
|
+
(typeof e === 'object' && !(e instanceof Event))
|
|
44
|
+
) {
|
|
45
|
+
val = e;
|
|
46
|
+
} else {
|
|
47
|
+
val = null;
|
|
48
|
+
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
const isComposing = composingRef.current || e?.nativeEvent?.isComposing;
|
|
51
|
+
if (isComposing) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
model.setProps({ value: val });
|
|
56
|
+
if (!composingRef.current) {
|
|
57
|
+
onChange?.(val);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
[model, onChange],
|
|
61
|
+
);
|
|
62
|
+
const handleCompositionStart = useCallback(() => {
|
|
55
63
|
composingRef.current = true;
|
|
56
|
-
};
|
|
64
|
+
}, []);
|
|
57
65
|
|
|
58
|
-
const handleCompositionEnd = (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
const handleCompositionEnd = useCallback(
|
|
67
|
+
(e: React.CompositionEvent<HTMLInputElement>, flag = true) => {
|
|
68
|
+
composingRef.current = false;
|
|
69
|
+
if (flag) {
|
|
70
|
+
handleChange(e);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
[handleChange],
|
|
74
|
+
);
|
|
64
75
|
|
|
65
|
-
const modelProps =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
const modelProps = useMemo(
|
|
77
|
+
() => ({
|
|
78
|
+
onCompositionStart: handleCompositionStart,
|
|
79
|
+
onCompositionEnd: handleCompositionEnd,
|
|
80
|
+
..._.omit(rest, flowModelRendererPropKeys),
|
|
81
|
+
onChange: handleChange,
|
|
82
|
+
}),
|
|
83
|
+
[handleChange, handleCompositionEnd, handleCompositionStart, rest],
|
|
84
|
+
);
|
|
71
85
|
useEffect(() => {
|
|
72
86
|
model && model.setProps(modelProps);
|
|
73
|
-
}, [modelProps]);
|
|
87
|
+
}, [model, modelProps]);
|
|
74
88
|
|
|
75
89
|
return <FlowModelRenderer model={model} {...rest} />;
|
|
76
90
|
}
|
|
@@ -92,6 +92,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
92
92
|
open,
|
|
93
93
|
onlyLeafSelectable = false,
|
|
94
94
|
ignoreFieldNames,
|
|
95
|
+
dropdownFooter,
|
|
95
96
|
...cascaderProps
|
|
96
97
|
}) => {
|
|
97
98
|
const { token } = theme.useToken();
|
|
@@ -360,12 +361,41 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
360
361
|
[cascaderOnDropdownVisibleChange, open],
|
|
361
362
|
);
|
|
362
363
|
|
|
364
|
+
// Footer hint at the bottom of the dropdown. Defaults to the "double click to choose entire object" hint whenever
|
|
365
|
+
// non-leaf selection is allowed (double-clicking a non-leaf selects the whole object). Callers can override with
|
|
366
|
+
// their own node, or pass `null` to hide it.
|
|
367
|
+
const footerNode = useMemo(() => {
|
|
368
|
+
if (dropdownFooter !== undefined) {
|
|
369
|
+
return dropdownFooter;
|
|
370
|
+
}
|
|
371
|
+
if (onlyLeafSelectable) {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
return (
|
|
375
|
+
<div
|
|
376
|
+
className={css`
|
|
377
|
+
padding: 6px 12px;
|
|
378
|
+
color: ${token.colorTextDescription};
|
|
379
|
+
border-top: 1px solid ${token.colorSplit};
|
|
380
|
+
font-size: ${token.fontSizeSM}px;
|
|
381
|
+
`}
|
|
382
|
+
>
|
|
383
|
+
{flowCtx.t('Double click to choose entire object')}
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
386
|
+
}, [dropdownFooter, onlyLeafSelectable, token, flowCtx]);
|
|
387
|
+
|
|
363
388
|
const renderDropdown = useCallback(
|
|
364
389
|
(menu: React.ReactElement) => {
|
|
365
390
|
const cascaderMenuNode = cascaderDropdownRender ? cascaderDropdownRender(menu) : menu;
|
|
366
391
|
const cascaderMenu = React.isValidElement(cascaderMenuNode) ? cascaderMenuNode : <>{cascaderMenuNode}</>;
|
|
367
392
|
if (!isSearchEnabled || children === null) {
|
|
368
|
-
return
|
|
393
|
+
return (
|
|
394
|
+
<>
|
|
395
|
+
{cascaderMenu}
|
|
396
|
+
{footerNode}
|
|
397
|
+
</>
|
|
398
|
+
);
|
|
369
399
|
}
|
|
370
400
|
|
|
371
401
|
return (
|
|
@@ -381,10 +411,11 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
381
411
|
/>
|
|
382
412
|
</div>
|
|
383
413
|
{cascaderMenu}
|
|
414
|
+
{footerNode}
|
|
384
415
|
</>
|
|
385
416
|
);
|
|
386
417
|
},
|
|
387
|
-
[cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText],
|
|
418
|
+
[cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText, footerNode],
|
|
388
419
|
);
|
|
389
420
|
|
|
390
421
|
const inlinePlaceholder =
|
|
@@ -0,0 +1,165 @@
|
|
|
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 { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { vi } from 'vitest';
|
|
13
|
+
import { FlowEngine } from '../../flowEngine';
|
|
14
|
+
import { FlowModel } from '../../models/flowModel';
|
|
15
|
+
import { FlowEngineProvider } from '../../provider';
|
|
16
|
+
import { FieldModelRenderer } from '../FieldModelRenderer';
|
|
17
|
+
|
|
18
|
+
class InputModel extends FlowModel {
|
|
19
|
+
render(): React.ReactNode {
|
|
20
|
+
return (
|
|
21
|
+
<input
|
|
22
|
+
data-testid="field-input"
|
|
23
|
+
value={this.props.value ?? ''}
|
|
24
|
+
onChange={this.props.onChange}
|
|
25
|
+
onCompositionStart={this.props.onCompositionStart}
|
|
26
|
+
onCompositionEnd={this.props.onCompositionEnd}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('FieldModelRenderer', () => {
|
|
33
|
+
it('updates model and form value for normal input changes', async () => {
|
|
34
|
+
const flowEngine = new FlowEngine();
|
|
35
|
+
const model = new InputModel({
|
|
36
|
+
uid: 'normal-input-field-model',
|
|
37
|
+
flowEngine,
|
|
38
|
+
props: {
|
|
39
|
+
value: '',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
model.dispatchEvent = vi.fn().mockResolvedValue([]);
|
|
43
|
+
const onChange = vi.fn();
|
|
44
|
+
|
|
45
|
+
render(
|
|
46
|
+
<FlowEngineProvider engine={flowEngine}>
|
|
47
|
+
<FieldModelRenderer model={model} onChange={onChange} />
|
|
48
|
+
</FlowEngineProvider>,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const input = await screen.findByTestId('field-input');
|
|
52
|
+
await waitFor(() => {
|
|
53
|
+
expect(model.props.onChange).toBeTypeOf('function');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
fireEvent.change(input, { target: { value: 'abc' } });
|
|
57
|
+
|
|
58
|
+
expect(model.props.value).toBe('abc');
|
|
59
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(onChange).toHaveBeenCalledWith('abc');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('updates model and form value when field components pass values directly', async () => {
|
|
64
|
+
const flowEngine = new FlowEngine();
|
|
65
|
+
const model = new InputModel({
|
|
66
|
+
uid: 'direct-value-field-model',
|
|
67
|
+
flowEngine,
|
|
68
|
+
props: {
|
|
69
|
+
value: '',
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
model.dispatchEvent = vi.fn().mockResolvedValue([]);
|
|
73
|
+
const onChange = vi.fn();
|
|
74
|
+
|
|
75
|
+
render(
|
|
76
|
+
<FlowEngineProvider engine={flowEngine}>
|
|
77
|
+
<FieldModelRenderer model={model} onChange={onChange} />
|
|
78
|
+
</FlowEngineProvider>,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await screen.findByTestId('field-input');
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
expect(model.props.onChange).toBeTypeOf('function');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
act(() => {
|
|
87
|
+
model.props.onChange(123);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(model.props.value).toBe(123);
|
|
91
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(onChange).toHaveBeenCalledWith(123);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('defers model and form value updates while IME composition is active', async () => {
|
|
96
|
+
const flowEngine = new FlowEngine();
|
|
97
|
+
const model = new InputModel({
|
|
98
|
+
uid: 'input-field-model',
|
|
99
|
+
flowEngine,
|
|
100
|
+
props: {
|
|
101
|
+
value: '',
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
model.dispatchEvent = vi.fn().mockResolvedValue([]);
|
|
105
|
+
const onChange = vi.fn();
|
|
106
|
+
|
|
107
|
+
render(
|
|
108
|
+
<FlowEngineProvider engine={flowEngine}>
|
|
109
|
+
<FieldModelRenderer model={model} onChange={onChange} />
|
|
110
|
+
</FlowEngineProvider>,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const input = await screen.findByTestId('field-input');
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
expect(model.props.onChange).toBeTypeOf('function');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
fireEvent.compositionStart(input);
|
|
119
|
+
fireEvent.change(input, { target: { value: 't' }, nativeEvent: { isComposing: true } });
|
|
120
|
+
|
|
121
|
+
expect(model.props.value).toBe('');
|
|
122
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
123
|
+
|
|
124
|
+
fireEvent.compositionEnd(input, { target: { value: 'te' } });
|
|
125
|
+
|
|
126
|
+
expect(model.props.value).toBe('te');
|
|
127
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(onChange).toHaveBeenCalledWith('te');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does not commit composition value when composition end opts out', async () => {
|
|
132
|
+
const flowEngine = new FlowEngine();
|
|
133
|
+
const model = new InputModel({
|
|
134
|
+
uid: 'composition-opt-out-field-model',
|
|
135
|
+
flowEngine,
|
|
136
|
+
props: {
|
|
137
|
+
value: '',
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
model.dispatchEvent = vi.fn().mockResolvedValue([]);
|
|
141
|
+
const onChange = vi.fn();
|
|
142
|
+
|
|
143
|
+
render(
|
|
144
|
+
<FlowEngineProvider engine={flowEngine}>
|
|
145
|
+
<FieldModelRenderer model={model} onChange={onChange} />
|
|
146
|
+
</FlowEngineProvider>,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
await screen.findByTestId('field-input');
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
expect(model.props.onChange).toBeTypeOf('function');
|
|
152
|
+
expect(model.props.onCompositionStart).toBeTypeOf('function');
|
|
153
|
+
expect(model.props.onCompositionEnd).toBeTypeOf('function');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
act(() => {
|
|
157
|
+
model.props.onCompositionStart();
|
|
158
|
+
model.props.onChange({ target: { value: 'search' }, nativeEvent: { isComposing: true } });
|
|
159
|
+
model.props.onCompositionEnd({ target: { value: 'search' } }, false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(model.props.value).toBe('');
|
|
163
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -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,
|