@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.
Files changed (84) hide show
  1. package/lib/JSRunner.d.ts +1 -0
  2. package/lib/JSRunner.js +110 -20
  3. package/lib/components/FieldModelRenderer.js +44 -31
  4. package/lib/components/FlowContextSelector.js +24 -3
  5. package/lib/components/dnd/index.js +9 -2
  6. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
  7. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
  8. package/lib/components/variables/VariableHybridInput.d.ts +8 -0
  9. package/lib/components/variables/VariableHybridInput.js +128 -12
  10. package/lib/components/variables/types.d.ts +8 -0
  11. package/lib/flowContext.d.ts +1 -1
  12. package/lib/flowContext.js +87 -36
  13. package/lib/flowI18n.js +3 -3
  14. package/lib/flowSettings.d.ts +5 -1
  15. package/lib/flowSettings.js +70 -0
  16. package/lib/locale/en-US.json +1 -0
  17. package/lib/locale/index.d.ts +2 -0
  18. package/lib/locale/zh-CN.json +1 -0
  19. package/lib/resources/apiResource.js +2 -1
  20. package/lib/resources/baseRecordResource.js +6 -17
  21. package/lib/resources/multiRecordResource.js +13 -3
  22. package/lib/resources/singleRecordResource.js +7 -2
  23. package/lib/runjs-context/helpers.js +12 -5
  24. package/lib/types.d.ts +12 -0
  25. package/lib/utils/dataSourceDirty.d.ts +20 -0
  26. package/lib/utils/dataSourceDirty.js +139 -0
  27. package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
  28. package/lib/utils/dirtyAwareApiClient.js +378 -0
  29. package/lib/utils/index.d.ts +1 -1
  30. package/lib/utils/index.js +11 -11
  31. package/lib/utils/openViewRouteState.d.ts +28 -0
  32. package/lib/utils/openViewRouteState.js +125 -0
  33. package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
  34. package/lib/utils/parsePathnameToViewParams.js +18 -1
  35. package/lib/utils/resolveRunJSObjectValues.js +3 -2
  36. package/lib/utils/runjsModuleLoader.js +0 -30
  37. package/lib/views/ViewNavigation.js +5 -0
  38. package/package.json +4 -4
  39. package/src/JSRunner.ts +112 -25
  40. package/src/__tests__/JSRunner.test.ts +4 -5
  41. package/src/__tests__/flowContext.test.ts +131 -0
  42. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
  43. package/src/__tests__/flowI18n.test.ts +11 -0
  44. package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
  45. package/src/__tests__/flowSettings.test.ts +72 -0
  46. package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
  47. package/src/components/FieldModelRenderer.tsx +50 -36
  48. package/src/components/FlowContextSelector.tsx +33 -2
  49. package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
  50. package/src/components/dnd/index.tsx +11 -2
  51. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
  52. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
  53. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
  54. package/src/components/variables/VariableHybridInput.tsx +166 -9
  55. package/src/components/variables/__tests__/VariableHybridInput.test.tsx +178 -0
  56. package/src/components/variables/types.ts +8 -0
  57. package/src/flowContext.ts +100 -33
  58. package/src/flowI18n.ts +8 -3
  59. package/src/flowSettings.ts +85 -1
  60. package/src/locale/en-US.json +1 -0
  61. package/src/locale/zh-CN.json +1 -0
  62. package/src/resources/apiResource.ts +2 -1
  63. package/src/resources/baseRecordResource.ts +6 -23
  64. package/src/resources/multiRecordResource.ts +13 -3
  65. package/src/resources/singleRecordResource.ts +6 -1
  66. package/src/runjs-context/helpers.ts +12 -6
  67. package/src/types.ts +14 -0
  68. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +392 -0
  69. package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
  70. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
  71. package/src/utils/dataSourceDirty.ts +126 -0
  72. package/src/utils/dirtyAwareApiClient.ts +430 -0
  73. package/src/utils/index.ts +10 -9
  74. package/src/utils/openViewRouteState.ts +107 -0
  75. package/src/utils/parsePathnameToViewParams.ts +23 -1
  76. package/src/utils/resolveRunJSObjectValues.ts +5 -2
  77. package/src/utils/runjsModuleLoader.ts +0 -32
  78. package/src/views/ViewNavigation.ts +6 -1
  79. package/src/views/__tests__/ViewNavigation.test.ts +15 -0
  80. package/lib/utils/safeGlobals.d.ts +0 -28
  81. package/lib/utils/safeGlobals.js +0 -367
  82. package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
  83. package/src/utils/__tests__/safeGlobals.test.ts +0 -106
  84. 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 = (e: any) => {
35
- let val;
36
- if (e && e.target && typeof e.target.value !== 'undefined') {
37
- val = e.target.value;
38
- } else if (
39
- typeof e === 'string' ||
40
- typeof e === 'number' ||
41
- typeof e === 'boolean' ||
42
- (typeof e === 'object' && !(e instanceof Event))
43
- ) {
44
- val = e;
45
- } else {
46
- val = null;
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
- model.setProps({ value: val });
50
- if (!composingRef.current) {
51
- props.onChange?.(val);
52
- }
53
- };
54
- const handleCompositionStart = () => {
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 = (e: React.CompositionEvent<HTMLInputElement>, flag = true) => {
59
- composingRef.current = false;
60
- if (flag) {
61
- props.onChange(e);
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
- onCompositionStart: handleCompositionStart,
67
- onCompositionEnd: handleCompositionEnd,
68
- ..._.omit(rest, flowModelRendererPropKeys),
69
- onChange: handleChange,
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 cascaderMenu;
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 = floatToolbarContainer?.getAttribute('data-model-uid');
69
+ const toolbarModelUid = getToolbarModelUidFromNode(activatorNode);
66
70
 
67
71
  if (!ownerDocument || !toolbarModelUid) {
68
72
  return activatorNode;
@@ -95,6 +99,7 @@ export const DragHandler: FC<{ model: FlowModel; children?: React.ReactNode }> =
95
99
  const dragHandlerRef = useRef<HTMLSpanElement | null>(null);
96
100
  const draggableNodeRef = useRef<HTMLElement | null>(null);
97
101
  const pointerPressCleanupRef = useRef<(() => void) | null>(null);
102
+ const toolbarDragModelUidRef = useRef<string | null>(null);
98
103
  const isDraggingRef = useRef(isDragging);
99
104
  const isPointerPressActiveRef = useRef(false);
100
105
  const isToolbarDragActiveRef = useRef(false);
@@ -126,9 +131,13 @@ export const DragHandler: FC<{ model: FlowModel; children?: React.ReactNode }> =
126
131
  return;
127
132
  }
128
133
 
134
+ const toolbarModelUid =
135
+ getToolbarModelUidFromNode(dragHandlerRef.current) || toolbarDragModelUidRef.current || model.uid;
136
+ toolbarDragModelUidRef.current = active ? toolbarModelUid : null;
137
+
129
138
  ownerDocument.dispatchEvent(
130
139
  new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
131
- detail: { active, modelUid: model.uid },
140
+ detail: { active, modelUid: toolbarModelUid },
132
141
  }),
133
142
  );
134
143
  },
@@ -11,6 +11,7 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
11
11
  import { createPortal } from 'react-dom';
12
12
  import { Alert, Space } from 'antd';
13
13
  import { css } from '@emotion/css';
14
+ import { useMemoizedFn } from 'ahooks';
14
15
  import { FlowModel } from '../../../../models';
15
16
  import { ToolbarItemConfig } from '../../../../types';
16
17
  import { useFlowModelById } from '../../../../hooks';
@@ -394,6 +395,49 @@ const isModelByIdProps = (props: FlowsFloatContextMenuProps): props is ModelById
394
395
  return 'uid' in props && 'modelClassName' in props && Boolean(props.uid) && Boolean(props.modelClassName);
395
396
  };
396
397
 
398
+ const stopResizeInteractionEvent = (event?: MouseEvent | React.MouseEvent) => {
399
+ if (!event) {
400
+ return;
401
+ }
402
+
403
+ event.preventDefault();
404
+ event.stopPropagation();
405
+
406
+ const nativeEvent = 'nativeEvent' in event ? event.nativeEvent : event;
407
+ nativeEvent.stopImmediatePropagation?.();
408
+ };
409
+
410
+ const pendingResizeClickSuppressions = new WeakMap<
411
+ Document,
412
+ { listener: (event: MouseEvent) => void; timer: number }
413
+ >();
414
+
415
+ const clearPendingResizeClickSuppression = (ownerDocument: Document) => {
416
+ const pending = pendingResizeClickSuppressions.get(ownerDocument);
417
+ if (!pending) {
418
+ return;
419
+ }
420
+
421
+ ownerDocument.removeEventListener('click', pending.listener, true);
422
+ (ownerDocument.defaultView || window).clearTimeout(pending.timer);
423
+ pendingResizeClickSuppressions.delete(ownerDocument);
424
+ };
425
+
426
+ const suppressNextResizeClick = (ownerDocument: Document) => {
427
+ clearPendingResizeClickSuppression(ownerDocument);
428
+
429
+ const listener = (event: MouseEvent) => {
430
+ stopResizeInteractionEvent(event);
431
+ clearPendingResizeClickSuppression(ownerDocument);
432
+ };
433
+ const timer = (ownerDocument.defaultView || window).setTimeout(() => {
434
+ clearPendingResizeClickSuppression(ownerDocument);
435
+ }, 0);
436
+
437
+ pendingResizeClickSuppressions.set(ownerDocument, { listener, timer });
438
+ ownerDocument.addEventListener('click', listener, true);
439
+ };
440
+
397
441
  /**
398
442
  * FlowsFloatContextMenu组件 - 悬浮配置工具栏组件
399
443
  *
@@ -442,55 +486,80 @@ const ResizeHandles: React.FC<{
442
486
  const isDraggingRef = useRef<boolean>(false);
443
487
  const dragTypeRef = useRef<'left' | 'right' | null>(null);
444
488
  const dragStartPosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
489
+ const dragOwnerDocumentRef = useRef<Document | null>(null);
445
490
  const { onDragStart, onDragEnd } = props;
446
491
 
447
492
  // 把拖拽位移转成上层已约定的 resize 事件。
448
- const handleDragMove = useCallback(
449
- (e: MouseEvent) => {
450
- if (!isDraggingRef.current || !dragTypeRef.current) return;
451
-
452
- const deltaX = e.clientX - dragStartPosRef.current.x;
453
-
454
- switch (dragTypeRef.current) {
455
- case 'left':
456
- props.model.parent.emitter.emit('onResizeLeft', { resizeDistance: -deltaX, model: props.model });
457
- break;
458
- case 'right':
459
- props.model.parent.emitter.emit('onResizeRight', { resizeDistance: deltaX, model: props.model });
460
- break;
461
- }
462
- },
463
- [props.model],
464
- );
493
+ const handleDragMove = useMemoizedFn((e: MouseEvent) => {
494
+ if (!isDraggingRef.current || !dragTypeRef.current) return;
495
+
496
+ stopResizeInteractionEvent(e);
497
+ const deltaX = e.clientX - dragStartPosRef.current.x;
498
+
499
+ switch (dragTypeRef.current) {
500
+ case 'left':
501
+ props.model.parent.emitter.emit('onResizeLeft', { resizeDistance: -deltaX, model: props.model });
502
+ break;
503
+ case 'right':
504
+ props.model.parent.emitter.emit('onResizeRight', { resizeDistance: deltaX, model: props.model });
505
+ break;
506
+ }
507
+ });
508
+
509
+ const handleDragEnd = useMemoizedFn((e?: MouseEvent) => {
510
+ if (!isDraggingRef.current) {
511
+ return;
512
+ }
513
+
514
+ stopResizeInteractionEvent(e);
515
+ const ownerDocument = dragOwnerDocumentRef.current || document;
516
+
517
+ ownerDocument.removeEventListener('mousemove', handleDragMove, true);
518
+ ownerDocument.removeEventListener('mouseup', handleDragEnd, true);
519
+ suppressNextResizeClick(ownerDocument);
465
520
 
466
- const handleDragEnd = useCallback(() => {
467
521
  isDraggingRef.current = false;
468
522
  dragTypeRef.current = null;
469
523
  dragStartPosRef.current = { x: 0, y: 0 };
470
-
471
- document.removeEventListener('mousemove', handleDragMove);
472
- document.removeEventListener('mouseup', handleDragEnd);
524
+ dragOwnerDocumentRef.current = null;
473
525
 
474
526
  props.model.parent.emitter.emit('onResizeEnd');
475
527
  onDragEnd?.();
476
- }, [handleDragMove, onDragEnd, props.model]);
528
+ });
529
+
530
+ useEffect(() => {
531
+ return () => {
532
+ const dragOwnerDocument = dragOwnerDocumentRef.current;
533
+ dragOwnerDocument?.removeEventListener('mousemove', handleDragMove, true);
534
+ dragOwnerDocument?.removeEventListener('mouseup', handleDragEnd, true);
535
+
536
+ if (isDraggingRef.current) {
537
+ suppressNextResizeClick(dragOwnerDocument || document);
538
+ props.model.parent.emitter.emit('onResizeEnd');
539
+ onDragEnd?.();
540
+ }
477
541
 
478
- const handleDragStart = useCallback(
479
- (e: React.MouseEvent, type: 'left' | 'right') => {
480
- e.preventDefault();
481
- e.stopPropagation();
542
+ isDraggingRef.current = false;
543
+ dragTypeRef.current = null;
544
+ dragStartPosRef.current = { x: 0, y: 0 };
545
+ dragOwnerDocumentRef.current = null;
546
+ };
547
+ }, [handleDragMove, handleDragEnd, onDragEnd, props.model]);
482
548
 
483
- isDraggingRef.current = true;
484
- dragTypeRef.current = type;
485
- dragStartPosRef.current = { x: e.clientX, y: e.clientY };
549
+ const handleDragStart = useMemoizedFn((e: React.MouseEvent, type: 'left' | 'right') => {
550
+ stopResizeInteractionEvent(e);
551
+ const ownerDocument = e.currentTarget.ownerDocument;
486
552
 
487
- document.addEventListener('mousemove', handleDragMove);
488
- document.addEventListener('mouseup', handleDragEnd);
553
+ isDraggingRef.current = true;
554
+ dragTypeRef.current = type;
555
+ dragStartPosRef.current = { x: e.clientX, y: e.clientY };
556
+ dragOwnerDocumentRef.current = ownerDocument;
489
557
 
490
- onDragStart?.();
491
- },
492
- [handleDragMove, handleDragEnd, onDragStart],
493
- );
558
+ ownerDocument.addEventListener('mousemove', handleDragMove, true);
559
+ ownerDocument.addEventListener('mouseup', handleDragEnd, true);
560
+
561
+ onDragStart?.();
562
+ });
494
563
 
495
564
  return (
496
565
  <>
@@ -601,6 +670,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
601
670
  getPopupContainer,
602
671
  handleSettingsMenuOpenChange,
603
672
  model,
673
+ modelUid,
604
674
  settingsMenuLevel,
605
675
  showCopyUidButton,
606
676
  showDeleteButton,