@nocobase/flow-engine 2.1.10 → 2.1.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 (48) hide show
  1. package/lib/components/dnd/index.js +9 -2
  2. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
  3. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
  4. package/lib/flowContext.d.ts +1 -1
  5. package/lib/flowContext.js +32 -8
  6. package/lib/locale/en-US.json +1 -0
  7. package/lib/locale/index.d.ts +2 -0
  8. package/lib/locale/zh-CN.json +1 -0
  9. package/lib/resources/apiResource.js +2 -1
  10. package/lib/resources/baseRecordResource.js +6 -17
  11. package/lib/resources/multiRecordResource.js +13 -3
  12. package/lib/resources/singleRecordResource.js +7 -2
  13. package/lib/utils/dataSourceDirty.d.ts +20 -0
  14. package/lib/utils/dataSourceDirty.js +139 -0
  15. package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
  16. package/lib/utils/dirtyAwareApiClient.js +378 -0
  17. package/lib/utils/index.d.ts +1 -0
  18. package/lib/utils/index.js +11 -0
  19. package/lib/utils/openViewRouteState.d.ts +28 -0
  20. package/lib/utils/openViewRouteState.js +125 -0
  21. package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
  22. package/lib/utils/parsePathnameToViewParams.js +18 -1
  23. package/lib/views/ViewNavigation.js +5 -0
  24. package/package.json +4 -4
  25. package/src/__tests__/flowContext.test.ts +131 -0
  26. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
  27. package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
  28. package/src/components/dnd/index.tsx +11 -2
  29. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
  30. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
  31. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
  32. package/src/flowContext.ts +45 -8
  33. package/src/locale/en-US.json +1 -0
  34. package/src/locale/zh-CN.json +1 -0
  35. package/src/resources/apiResource.ts +2 -1
  36. package/src/resources/baseRecordResource.ts +6 -23
  37. package/src/resources/multiRecordResource.ts +13 -3
  38. package/src/resources/singleRecordResource.ts +6 -1
  39. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +392 -0
  40. package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
  41. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
  42. package/src/utils/dataSourceDirty.ts +126 -0
  43. package/src/utils/dirtyAwareApiClient.ts +430 -0
  44. package/src/utils/index.ts +10 -0
  45. package/src/utils/openViewRouteState.ts +107 -0
  46. package/src/utils/parsePathnameToViewParams.ts +23 -1
  47. package/src/views/ViewNavigation.ts +6 -1
  48. package/src/views/__tests__/ViewNavigation.test.ts +15 -0
@@ -32,6 +32,7 @@ __export(ViewNavigation_exports, {
32
32
  });
33
33
  module.exports = __toCommonJS(ViewNavigation_exports);
34
34
  var import_reactive = require("../reactive");
35
+ var import_utils = require("../utils");
35
36
  function encodeFilterByTk(val) {
36
37
  if (val === void 0 || val === null) return "";
37
38
  if (val && typeof val === "object" && !Array.isArray(val)) {
@@ -63,6 +64,10 @@ function generatePathnameFromViewParams(viewParams, options = {}) {
63
64
  segments.push("view");
64
65
  }
65
66
  segments.push(viewParam.viewUid);
67
+ const openViewRouteStateToken = (0, import_utils.encodeOpenViewRouteState)(viewParam.viewUid, viewParam.openViewRouteState);
68
+ if (openViewRouteStateToken) {
69
+ segments.push("opts", openViewRouteStateToken);
70
+ }
66
71
  if (viewParam.tabUid) {
67
72
  segments.push("tab", viewParam.tabUid);
68
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.1.10",
3
+ "version": "2.1.12",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.1.10",
12
- "@nocobase/shared": "2.1.10",
11
+ "@nocobase/sdk": "2.1.12",
12
+ "@nocobase/shared": "2.1.12",
13
13
  "ahooks": "^3.7.2",
14
14
  "axios": "^1.7.0",
15
15
  "dayjs": "^1.11.9",
@@ -37,5 +37,5 @@
37
37
  ],
38
38
  "author": "NocoBase Team",
39
39
  "license": "Apache-2.0",
40
- "gitHead": "65d5bc38d72d5a6fca8bcf9798a0b976e382cac9"
40
+ "gitHead": "3a69ed089fbb5a3e8700c0134f83d654f0190d25"
41
41
  }
@@ -14,6 +14,8 @@ import { FlowEngine } from '../flowEngine';
14
14
  import { FlowModel } from '../models/flowModel';
15
15
  import { RunJSContextRegistry } from '../runjs-context/registry';
16
16
  import { setupRunJSContexts } from '../runjs-context/setup';
17
+ import { createViewScopedEngine } from '../ViewScopedFlowEngine';
18
+ import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
17
19
 
18
20
  describe('FlowContext properties and methods', () => {
19
21
  it('should return static property value', () => {
@@ -177,6 +179,49 @@ describe('FlowContext properties and methods', () => {
177
179
  });
178
180
  });
179
181
 
182
+ it('should expose current role as a top-level variable', async () => {
183
+ const engine = new FlowEngine();
184
+ const ctx = engine.context;
185
+ ctx.defineProperty('api', { value: { auth: { role: 'admin' } } });
186
+
187
+ expect(ctx.role).toBe('admin');
188
+ await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toBe('admin');
189
+
190
+ const roleNode = ctx.getPropertyMetaTree().find((node) => node.name === 'role');
191
+ expect(roleNode).toMatchObject({
192
+ name: 'role',
193
+ title: '{{t("Current role")}}',
194
+ paths: ['role'],
195
+ });
196
+ });
197
+
198
+ it('should expose actual role names for union role mode', async () => {
199
+ const engine = new FlowEngine();
200
+ const ctx = engine.context;
201
+ ctx.defineProperty('api', { value: { auth: { role: '__union__' } } });
202
+ ctx.defineProperty('user', {
203
+ value: {
204
+ roles: [
205
+ { name: 'admin', title: 'Admin' },
206
+ { name: 'member', title: 'Member' },
207
+ ],
208
+ },
209
+ });
210
+
211
+ expect(ctx.role).toEqual(['admin', 'member']);
212
+ await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toEqual(['admin', 'member']);
213
+ });
214
+
215
+ it('should expose an empty role list for union role mode without user roles', async () => {
216
+ const engine = new FlowEngine();
217
+ const ctx = engine.context;
218
+ ctx.defineProperty('api', { value: { auth: { role: '__union__' } } });
219
+ ctx.defineProperty('user', { value: {} });
220
+
221
+ expect(ctx.role).toEqual([]);
222
+ await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toEqual([]);
223
+ });
224
+
180
225
  it('should throw sync error in get', () => {
181
226
  const ctx = new FlowContext();
182
227
  ctx.defineProperty('error', {
@@ -1386,6 +1431,92 @@ describe('FlowEngine context', () => {
1386
1431
  expect(engine.context.appName).toBe('NocoBase');
1387
1432
  });
1388
1433
 
1434
+ it('ctx.api should return a dirty-aware wrapper for static api properties', async () => {
1435
+ const engine = new FlowEngine();
1436
+ const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
1437
+ const api = {
1438
+ auth: { locale: 'zh-CN' },
1439
+ request: vi.fn(async () => ({ data: { ok: true } })),
1440
+ resource: vi.fn(() => ({ update })),
1441
+ };
1442
+ engine.context.defineProperty('api', { value: api });
1443
+
1444
+ await engine.context.api.resource('posts').update({ filterByTk: 1, values: { title: 't' } });
1445
+
1446
+ expect(update).toHaveBeenCalledTimes(1);
1447
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1448
+ expect(engine.context.api).toBe(engine.context.api);
1449
+ });
1450
+
1451
+ it('ctx.api should stay dirty-aware when resolved from a scoped context delegate', async () => {
1452
+ const root = new FlowEngine();
1453
+ const scoped = createViewScopedEngine(root);
1454
+ const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
1455
+ root.context.defineProperty('api', {
1456
+ value: {
1457
+ auth: { locale: 'zh-CN' },
1458
+ request: vi.fn(async () => ({ data: { ok: true } })),
1459
+ resource: vi.fn(() => ({ update })),
1460
+ },
1461
+ });
1462
+
1463
+ await scoped.context.api.resource('posts').update({ filterByTk: 1, values: { title: 't' } });
1464
+
1465
+ expect(update).toHaveBeenCalledTimes(1);
1466
+ expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1467
+ expect(scoped.context.engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1468
+ });
1469
+
1470
+ it('ctx.request should use the dirty-aware api wrapper', async () => {
1471
+ const engine = new FlowEngine();
1472
+ const request = vi.fn(async () => ({ data: { ok: true } }));
1473
+ engine.context.defineProperty('api', {
1474
+ value: {
1475
+ auth: { locale: 'zh-CN' },
1476
+ request,
1477
+ resource: vi.fn(),
1478
+ },
1479
+ });
1480
+
1481
+ await engine.context.request({
1482
+ resource: 'posts',
1483
+ action: 'update',
1484
+ headers: { 'X-Data-Source': 'analytics' },
1485
+ params: { filterByTk: 1 },
1486
+ } as any);
1487
+
1488
+ expect(request).toHaveBeenCalledTimes(1);
1489
+ expect(engine.getDataSourceDirtyVersion('analytics', 'posts')).toBe(1);
1490
+ });
1491
+
1492
+ it('ctx.request should use the caller context when resolved through a scoped delegate', async () => {
1493
+ const root = new FlowEngine();
1494
+ const scoped = createViewScopedEngine(root);
1495
+ const callerCtx = new FlowContext();
1496
+ const dirtyEvents: Array<{ dataSourceKey: string; resourceNames: string[] }> = [];
1497
+ const request = vi.fn(async () => ({ data: { ok: true } }));
1498
+ root.context.defineProperty('api', {
1499
+ value: {
1500
+ auth: { locale: 'zh-CN' },
1501
+ request,
1502
+ resource: vi.fn(),
1503
+ },
1504
+ });
1505
+ callerCtx.addDelegate(scoped.context);
1506
+ scoped.context.engine.emitter.on(DATA_SOURCE_DIRTY_EVENT, (event) => dirtyEvents.push(event));
1507
+
1508
+ await callerCtx.request({
1509
+ resource: 'posts',
1510
+ action: 'update',
1511
+ params: { filterByTk: 1 },
1512
+ } as any);
1513
+
1514
+ expect(request).toHaveBeenCalledTimes(1);
1515
+ expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1516
+ expect(scoped.context.engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1517
+ expect(dirtyEvents).toEqual([{ dataSourceKey: 'main', resourceNames: ['posts'] }]);
1518
+ });
1519
+
1389
1520
  it('ctx.sql should resolve template variables from caller context in delegate chain', async () => {
1390
1521
  const engine = new FlowEngine();
1391
1522
  const request = vi.fn(async () => ({ data: { data: [] } }));
@@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest';
11
11
  import { FlowEngine } from '../flowEngine';
12
12
  import { MultiRecordResource } from '../resources/multiRecordResource';
13
13
  import { SingleRecordResource } from '../resources/singleRecordResource';
14
+ import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
14
15
 
15
16
  describe('FlowEngine dataSource dirty registry', () => {
16
17
  it('tracks versions per dataSourceKey + resourceName', () => {
@@ -60,4 +61,54 @@ describe('FlowEngine dataSource dirty registry', () => {
60
61
  // plus root collection (safety)
61
62
  expect(markSpy).toHaveBeenCalledWith('main', 'users');
62
63
  });
64
+
65
+ it('marks dirty once for record write helpers when using the dirty-aware context api', async () => {
66
+ const engine = new FlowEngine();
67
+ const request = vi.fn(async () => ({ data: { data: { id: 1 }, meta: {} } }));
68
+ const dirtyEvents: Array<{ dataSourceKey: string; resourceNames: string[] }> = [];
69
+ engine.context.defineProperty('api', {
70
+ value: {
71
+ auth: { locale: 'zh-CN' },
72
+ request,
73
+ resource: vi.fn(),
74
+ },
75
+ });
76
+ engine.emitter.on(DATA_SOURCE_DIRTY_EVENT, (event) => dirtyEvents.push(event));
77
+
78
+ const multi = engine.createResource(MultiRecordResource);
79
+ multi.setDataSourceKey('main').setResourceName('posts');
80
+ await multi.create({ title: 't' } as any, { refresh: false });
81
+
82
+ expect(request).toHaveBeenCalledTimes(1);
83
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
84
+ expect(dirtyEvents).toEqual([{ dataSourceKey: 'main', resourceNames: ['posts'] }]);
85
+
86
+ const single = engine.createResource(SingleRecordResource);
87
+ single.setDataSourceKey('main').setResourceName('posts').setFilterByTk(1);
88
+ await single.save({ title: 'u' } as any, { refresh: false });
89
+
90
+ expect(request).toHaveBeenCalledTimes(2);
91
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(2);
92
+ expect(dirtyEvents).toEqual([
93
+ { dataSourceKey: 'main', resourceNames: ['posts'] },
94
+ { dataSourceKey: 'main', resourceNames: ['posts'] },
95
+ ]);
96
+ });
97
+
98
+ it('still marks dirty for direct runAction writes', async () => {
99
+ const engine = new FlowEngine();
100
+ engine.context.defineProperty('api', {
101
+ value: {
102
+ auth: { locale: 'zh-CN' },
103
+ request: vi.fn(async () => ({ data: { data: { id: 1 }, meta: {} } })),
104
+ resource: vi.fn(),
105
+ },
106
+ });
107
+
108
+ const multi = engine.createResource(MultiRecordResource);
109
+ multi.setDataSourceKey('main').setResourceName('posts');
110
+ await multi.runAction('create', { data: { title: 't' } });
111
+
112
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
113
+ });
63
114
  });
@@ -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', () => {
@@ -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,