@nocobase/flow-engine 2.2.0-beta.5 → 2.2.0-beta.7

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 (30) hide show
  1. package/lib/JSRunner.d.ts +1 -0
  2. package/lib/JSRunner.js +110 -20
  3. package/lib/components/dnd/index.js +9 -2
  4. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
  5. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
  6. package/lib/flowContext.js +65 -30
  7. package/lib/runjs-context/helpers.js +12 -5
  8. package/lib/utils/index.d.ts +0 -1
  9. package/lib/utils/index.js +0 -11
  10. package/lib/utils/resolveRunJSObjectValues.js +3 -2
  11. package/lib/utils/runjsModuleLoader.js +0 -30
  12. package/package.json +4 -4
  13. package/src/JSRunner.ts +112 -25
  14. package/src/__tests__/JSRunner.test.ts +4 -5
  15. package/src/__tests__/flowContext.test.ts +43 -0
  16. package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
  17. package/src/components/dnd/index.tsx +11 -2
  18. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
  19. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
  20. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
  21. package/src/flowContext.ts +68 -26
  22. package/src/runjs-context/helpers.ts +12 -6
  23. package/src/utils/index.ts +0 -9
  24. package/src/utils/resolveRunJSObjectValues.ts +5 -2
  25. package/src/utils/runjsModuleLoader.ts +0 -32
  26. package/lib/utils/safeGlobals.d.ts +0 -28
  27. package/lib/utils/safeGlobals.js +0 -367
  28. package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
  29. package/src/utils/__tests__/safeGlobals.test.ts +0 -106
  30. package/src/utils/safeGlobals.ts +0 -406
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import React from 'react';
11
- import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react';
11
+ import { act, cleanup, fireEvent, render, waitFor, within } from '@testing-library/react';
12
12
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
13
  import { App, ConfigProvider } from 'antd';
14
14
  import { FlowEngine } from '../../../../../flowEngine';
@@ -17,7 +17,7 @@ import { FlowEngineProvider } from '../../../../../provider';
17
17
  import { FieldModelRenderer } from '../../../../FieldModelRenderer';
18
18
  import { FlowModelRenderer } from '../../../../FlowModelRenderer';
19
19
  import { FlowsFloatContextMenu } from '../FlowsFloatContextMenu';
20
- import { TOOLBAR_DRAG_ACTIVITY_EVENT } from '../../../../dnd';
20
+ import { DndProvider, DragHandler, TOOLBAR_DRAG_ACTIVITY_EVENT } from '../../../../dnd';
21
21
 
22
22
  const mockColorTextTertiary = '#8c8c8c';
23
23
 
@@ -182,12 +182,24 @@ const ToolbarDragItem = ({ model }: { model: FlowModel }) => {
182
182
  );
183
183
  };
184
184
 
185
+ const ForkedToolbarDragItem = ({ model }: { model: FlowModel }) => {
186
+ return (
187
+ <DragHandler model={model}>
188
+ <button type="button" aria-label="forked-toolbar-drag">
189
+ drag
190
+ </button>
191
+ </DragHandler>
192
+ );
193
+ };
194
+
185
195
  describe('FlowsFloatContextMenu', () => {
186
196
  const originalResizeObserver = globalThis.ResizeObserver;
187
197
  const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
188
198
  const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
199
+ const originalPointerEvent = globalThis.PointerEvent;
189
200
 
190
201
  beforeEach(() => {
202
+ globalThis.PointerEvent = MouseEvent as typeof PointerEvent;
191
203
  globalThis.ResizeObserver = class {
192
204
  observe = vi.fn();
193
205
  disconnect = vi.fn();
@@ -207,6 +219,7 @@ describe('FlowsFloatContextMenu', () => {
207
219
  globalThis.ResizeObserver = originalResizeObserver;
208
220
  globalThis.requestAnimationFrame = originalRequestAnimationFrame;
209
221
  globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
222
+ globalThis.PointerEvent = originalPointerEvent;
210
223
  document.body.innerHTML = '';
211
224
  });
212
225
 
@@ -545,28 +558,163 @@ describe('FlowsFloatContextMenu', () => {
545
558
  fireEvent.mouseEnter(icons, { relatedTarget: host });
546
559
 
547
560
  const dragButton = within(overlay).getByLabelText('toolbar-drag');
548
- dragButton.ownerDocument.dispatchEvent(
549
- new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
550
- detail: { active: true, modelUid: model.uid },
551
- }),
552
- );
561
+ act(() => {
562
+ dragButton.ownerDocument.dispatchEvent(
563
+ new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
564
+ detail: { active: true, modelUid: model.uid },
565
+ }),
566
+ );
567
+ });
553
568
  fireEvent.mouseLeave(icons, { relatedTarget: document.createElement('div') });
554
569
 
555
570
  await waitFor(() => {
556
571
  expect(queryOverlay(appContainer, 'drag-toolbar-model')?.className).toContain('nb-toolbar-visible');
557
572
  });
558
573
 
559
- dragButton.ownerDocument.dispatchEvent(
560
- new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
561
- detail: { active: false, modelUid: model.uid },
562
- }),
563
- );
574
+ act(() => {
575
+ dragButton.ownerDocument.dispatchEvent(
576
+ new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
577
+ detail: { active: false, modelUid: model.uid },
578
+ }),
579
+ );
580
+ });
564
581
 
565
582
  await waitFor(() => {
566
583
  expect(queryOverlay(appContainer, 'drag-toolbar-model')).toBeNull();
567
584
  });
568
585
  });
569
586
 
587
+ it('does not let resize handle drag release close the surrounding modal wrap', async () => {
588
+ const engine = new FlowEngine();
589
+ await engine.flowSettings.forceEnable();
590
+ const parentModel = createModel(engine, 'resize-parent-model');
591
+ const model = createModel(engine, 'resize-child-model');
592
+ model.setParent(parentModel);
593
+ const appContainer = createAppContainer();
594
+ const modalWrap = createPopupRoot('ant-modal-wrap');
595
+ const onModalWrapClick = vi.fn();
596
+ modalWrap.addEventListener('click', onModalWrapClick);
597
+ appContainer.appendChild(modalWrap);
598
+ mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
599
+ mockRect(modalWrap, { top: 100, left: 200, width: 640, height: 520 });
600
+
601
+ const { getByTestId } = renderWithProviders(
602
+ engine,
603
+ <FlowsFloatContextMenu model={model} showDragHandle>
604
+ <div data-testid="resize-content">content</div>
605
+ </FlowsFloatContextMenu>,
606
+ { container: modalWrap },
607
+ );
608
+
609
+ const host = getHost(getByTestId('resize-content'));
610
+ mockRect(host, { top: 140, left: 280, width: 220, height: 48 });
611
+
612
+ fireEvent.mouseEnter(host);
613
+
614
+ const overlay = await waitFor(() => {
615
+ const nextOverlay = modalWrap.querySelector('[data-model-uid="resize-child-model"]') as HTMLDivElement | null;
616
+ expect(nextOverlay).toBeTruthy();
617
+ return nextOverlay as HTMLDivElement;
618
+ });
619
+
620
+ const resizeHandle = overlay.querySelector('.resize-handle-right') as HTMLDivElement;
621
+ expect(resizeHandle).toBeTruthy();
622
+
623
+ fireEvent.mouseDown(resizeHandle, { clientX: 500, clientY: 164 });
624
+ fireEvent.mouseMove(document, { clientX: 460, clientY: 164 });
625
+ fireEvent.mouseUp(modalWrap, { clientX: 460, clientY: 164 });
626
+ fireEvent.click(modalWrap, { clientX: 460, clientY: 164 });
627
+
628
+ expect(onModalWrapClick).not.toHaveBeenCalled();
629
+ });
630
+
631
+ it('emits resize end when resize handles unmount during a drag', async () => {
632
+ const engine = new FlowEngine();
633
+ await engine.flowSettings.forceEnable();
634
+ const parentModel = createModel(engine, 'resize-unmount-parent-model');
635
+ const model = createModel(engine, 'resize-unmount-child-model');
636
+ model.setParent(parentModel);
637
+ const appContainer = createAppContainer();
638
+ const onResizeEnd = vi.fn();
639
+ parentModel.emitter.on('onResizeEnd', onResizeEnd);
640
+ mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
641
+
642
+ const { getByTestId, unmount } = renderWithProviders(
643
+ engine,
644
+ <FlowsFloatContextMenu model={model} showDragHandle>
645
+ <div data-testid="resize-unmount-content">content</div>
646
+ </FlowsFloatContextMenu>,
647
+ { container: appContainer },
648
+ );
649
+
650
+ const host = getHost(getByTestId('resize-unmount-content'));
651
+ mockRect(host, { top: 140, left: 280, width: 220, height: 48 });
652
+
653
+ fireEvent.mouseEnter(host);
654
+
655
+ const overlay = await waitFor(() => {
656
+ const nextOverlay = appContainer.querySelector(
657
+ '[data-model-uid="resize-unmount-child-model"]',
658
+ ) as HTMLDivElement | null;
659
+ expect(nextOverlay).toBeTruthy();
660
+ return nextOverlay as HTMLDivElement;
661
+ });
662
+
663
+ const resizeHandle = overlay.querySelector('.resize-handle-right') as HTMLDivElement;
664
+ expect(resizeHandle).toBeTruthy();
665
+
666
+ fireEvent.mouseDown(resizeHandle, { clientX: 500, clientY: 164 });
667
+ unmount();
668
+
669
+ expect(onResizeEnd).toHaveBeenCalledTimes(1);
670
+ });
671
+
672
+ it('keeps the resize release click suppressed after handles unmount', async () => {
673
+ const engine = new FlowEngine();
674
+ await engine.flowSettings.forceEnable();
675
+ const parentModel = createModel(engine, 'resize-release-unmount-parent-model');
676
+ const model = createModel(engine, 'resize-release-unmount-child-model');
677
+ model.setParent(parentModel);
678
+ const appContainer = createAppContainer();
679
+ const modalWrap = createPopupRoot('ant-modal-wrap');
680
+ const onModalWrapClick = vi.fn();
681
+ modalWrap.addEventListener('click', onModalWrapClick);
682
+ appContainer.appendChild(modalWrap);
683
+ mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
684
+ mockRect(modalWrap, { top: 100, left: 200, width: 640, height: 520 });
685
+
686
+ const { getByTestId, unmount } = renderWithProviders(
687
+ engine,
688
+ <FlowsFloatContextMenu model={model} showDragHandle>
689
+ <div data-testid="resize-release-unmount-content">content</div>
690
+ </FlowsFloatContextMenu>,
691
+ { container: modalWrap },
692
+ );
693
+
694
+ const host = getHost(getByTestId('resize-release-unmount-content'));
695
+ mockRect(host, { top: 140, left: 280, width: 220, height: 48 });
696
+
697
+ fireEvent.mouseEnter(host);
698
+
699
+ const overlay = await waitFor(() => {
700
+ const nextOverlay = modalWrap.querySelector(
701
+ '[data-model-uid="resize-release-unmount-child-model"]',
702
+ ) as HTMLDivElement | null;
703
+ expect(nextOverlay).toBeTruthy();
704
+ return nextOverlay as HTMLDivElement;
705
+ });
706
+
707
+ const resizeHandle = overlay.querySelector('.resize-handle-right') as HTMLDivElement;
708
+ expect(resizeHandle).toBeTruthy();
709
+
710
+ fireEvent.mouseDown(resizeHandle, { clientX: 500, clientY: 164 });
711
+ fireEvent.mouseUp(modalWrap, { clientX: 460, clientY: 164 });
712
+ unmount();
713
+ fireEvent.click(modalWrap, { clientX: 460, clientY: 164 });
714
+
715
+ expect(onModalWrapClick).not.toHaveBeenCalled();
716
+ });
717
+
570
718
  it('hides parent toolbar when hovering a nested child host', async () => {
571
719
  const engine = new FlowEngine();
572
720
  await engine.flowSettings.forceEnable();
@@ -714,6 +862,122 @@ describe('FlowsFloatContextMenu', () => {
714
862
  });
715
863
  });
716
864
 
865
+ it('restores parent toolbar when stale child activity remains but pointer is on the parent block', async () => {
866
+ const engine = new FlowEngine();
867
+ await engine.flowSettings.forceEnable();
868
+ const parentModel = createModel(engine, 'parent-stale-child-model');
869
+ const childModel = createModel(engine, 'child-stale-model');
870
+ const appContainer = createAppContainer();
871
+ mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
872
+
873
+ const { getByTestId } = renderWithProviders(
874
+ engine,
875
+ <FlowsFloatContextMenu model={parentModel}>
876
+ <div data-testid="parent-content">
877
+ <div data-testid="parent-gap">gap</div>
878
+ <FlowsFloatContextMenu model={childModel}>
879
+ <div data-testid="child-content">child</div>
880
+ </FlowsFloatContextMenu>
881
+ </div>
882
+ </FlowsFloatContextMenu>,
883
+ { container: appContainer },
884
+ );
885
+
886
+ const parentHost = getHost(getByTestId('parent-content'));
887
+ const childHost = getHost(getByTestId('child-content'));
888
+ const parentGap = getByTestId('parent-gap');
889
+ mockRect(parentHost, { top: 10, left: 10, width: 320, height: 160 });
890
+ mockRect(childHost, { top: 28, left: 36, width: 120, height: 48 });
891
+
892
+ fireEvent.mouseEnter(parentHost);
893
+
894
+ const parentOverlay = await waitFor(() => {
895
+ const nextOverlay = queryOverlay(appContainer, 'parent-stale-child-model');
896
+ expect(nextOverlay).toBeTruthy();
897
+ return nextOverlay as HTMLDivElement;
898
+ });
899
+
900
+ await waitFor(() => {
901
+ expect(within(parentOverlay).getByLabelText('flows-settings')).toBeTruthy();
902
+ });
903
+
904
+ act(() => {
905
+ childHost.dispatchEvent(
906
+ new CustomEvent('nb-float-menu-child-activity', {
907
+ bubbles: true,
908
+ detail: { active: true, modelUid: childModel.uid },
909
+ }),
910
+ );
911
+ });
912
+
913
+ await waitFor(() => {
914
+ expect(queryOverlay(appContainer, 'parent-stale-child-model')).toBeNull();
915
+ });
916
+
917
+ fireEvent.mouseMove(parentGap);
918
+
919
+ await waitFor(() => {
920
+ const parentOverlayAfterRestore = queryOverlay(appContainer, 'parent-stale-child-model');
921
+ expect(parentOverlayAfterRestore).toBeTruthy();
922
+ expect(parentOverlayAfterRestore?.className).toContain('nb-toolbar-visible');
923
+ });
924
+ });
925
+
926
+ it('keeps parent toolbar hidden when moving over the parent block while child toolbar is active', async () => {
927
+ const engine = new FlowEngine();
928
+ await engine.flowSettings.forceEnable();
929
+ const parentModel = createModel(engine, 'parent-active-child-model');
930
+ const childModel = createModel(engine, 'child-active-model');
931
+ const appContainer = createAppContainer();
932
+ mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
933
+
934
+ const { getByTestId } = renderWithProviders(
935
+ engine,
936
+ <FlowsFloatContextMenu model={parentModel}>
937
+ <div data-testid="parent-content">
938
+ <div data-testid="parent-gap">gap</div>
939
+ <FlowsFloatContextMenu model={childModel}>
940
+ <div data-testid="child-content">child</div>
941
+ </FlowsFloatContextMenu>
942
+ </div>
943
+ </FlowsFloatContextMenu>,
944
+ { container: appContainer },
945
+ );
946
+
947
+ const parentHost = getHost(getByTestId('parent-content'));
948
+ const childHost = getHost(getByTestId('child-content'));
949
+ const parentGap = getByTestId('parent-gap');
950
+ mockRect(parentHost, { top: 10, left: 10, width: 320, height: 160 });
951
+ mockRect(childHost, { top: 28, left: 36, width: 120, height: 48 });
952
+
953
+ fireEvent.mouseEnter(parentHost);
954
+
955
+ await waitFor(() => {
956
+ expect(queryOverlay(appContainer, 'parent-active-child-model')).toBeTruthy();
957
+ });
958
+
959
+ fireEvent.mouseEnter(childHost);
960
+ fireEvent.mouseMove(childHost);
961
+
962
+ const childOverlay = await waitFor(() => {
963
+ const nextOverlay = queryOverlay(appContainer, 'child-active-model');
964
+ expect(nextOverlay).toBeTruthy();
965
+ return nextOverlay as HTMLDivElement;
966
+ });
967
+
968
+ await waitFor(() => {
969
+ expect(childOverlay.className).toContain('nb-toolbar-visible');
970
+ expect(queryOverlay(appContainer, 'parent-active-child-model')).toBeNull();
971
+ });
972
+
973
+ fireEvent.mouseMove(parentGap);
974
+
975
+ await waitFor(() => {
976
+ expect(queryOverlay(appContainer, 'child-active-model')?.className).toContain('nb-toolbar-visible');
977
+ expect(queryOverlay(appContainer, 'parent-active-child-model')).toBeNull();
978
+ });
979
+ });
980
+
717
981
  it('treats forked models as distinct float menu instances even when they share the same uid', async () => {
718
982
  const engine = new FlowEngine();
719
983
  await engine.flowSettings.forceEnable();
@@ -775,4 +1039,109 @@ describe('FlowsFloatContextMenu', () => {
775
1039
  expect(firstOverlay.getAttribute('data-model-uid')).toBe(firstInstanceId);
776
1040
  expect(secondOverlay.getAttribute('data-model-uid')).toBe(secondInstanceId);
777
1041
  });
1042
+
1043
+ it('keeps a forked child toolbar active while dragging one of its toolbar items', async () => {
1044
+ const engine = new FlowEngine();
1045
+ await engine.flowSettings.forceEnable();
1046
+ const parentModel = createModel(engine, 'parent-forked-drag-model');
1047
+ const childMasterModel = new FlowModel({ uid: 'forked-drag-child-model', flowEngine: engine });
1048
+ childMasterModel.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
1049
+ const childFork = childMasterModel.createFork({}, 'drag-card') as FlowModel & { forkId?: string };
1050
+ const childInstanceId = `forked-drag-child-model::${String(childFork.forkId)}`;
1051
+ const appContainer = createAppContainer();
1052
+ mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
1053
+
1054
+ const { getByTestId } = renderWithProviders(
1055
+ engine,
1056
+ <DndProvider>
1057
+ <FlowsFloatContextMenu model={parentModel}>
1058
+ <div data-testid="parent-content">
1059
+ <div data-testid="parent-gap">gap</div>
1060
+ <FlowsFloatContextMenu
1061
+ model={childFork}
1062
+ extraToolbarItems={[
1063
+ {
1064
+ key: 'forked-toolbar-drag',
1065
+ component: ForkedToolbarDragItem,
1066
+ sort: 100,
1067
+ },
1068
+ ]}
1069
+ >
1070
+ <div data-testid="forked-child-content">child</div>
1071
+ </FlowsFloatContextMenu>
1072
+ </div>
1073
+ </FlowsFloatContextMenu>
1074
+ </DndProvider>,
1075
+ { container: appContainer },
1076
+ );
1077
+
1078
+ const parentHost = getHost(getByTestId('parent-content'));
1079
+ const childHost = getHost(getByTestId('forked-child-content'));
1080
+ const parentGap = getByTestId('parent-gap');
1081
+ mockRect(parentHost, { top: 10, left: 10, width: 320, height: 160 });
1082
+ mockRect(childHost, { top: 28, left: 36, width: 120, height: 48 });
1083
+
1084
+ fireEvent.mouseEnter(parentHost);
1085
+
1086
+ await waitFor(() => {
1087
+ expect(queryOverlay(appContainer, 'parent-forked-drag-model')).toBeTruthy();
1088
+ });
1089
+
1090
+ fireEvent.mouseEnter(childHost);
1091
+ fireEvent.mouseMove(childHost);
1092
+
1093
+ const childOverlay = await waitFor(() => {
1094
+ const nextOverlay = queryOverlay(appContainer, childInstanceId);
1095
+ expect(nextOverlay).toBeTruthy();
1096
+ return nextOverlay as HTMLDivElement;
1097
+ });
1098
+
1099
+ await waitFor(() => {
1100
+ expect(queryOverlay(appContainer, 'parent-forked-drag-model')).toBeNull();
1101
+ expect(within(childOverlay).getByLabelText('forked-toolbar-drag')).toBeTruthy();
1102
+ });
1103
+
1104
+ const childIcons = childOverlay.querySelector('.nb-toolbar-container-icons') as HTMLDivElement;
1105
+ fireEvent.mouseLeave(parentHost, { relatedTarget: childIcons });
1106
+ fireEvent.mouseLeave(childHost, { relatedTarget: childIcons });
1107
+ fireEvent.mouseEnter(childIcons, { relatedTarget: childHost });
1108
+
1109
+ const dragButton = within(childOverlay).getByLabelText('forked-toolbar-drag');
1110
+ const dragActivityListener = vi.fn();
1111
+ dragButton.ownerDocument.addEventListener(TOOLBAR_DRAG_ACTIVITY_EVENT, dragActivityListener);
1112
+ fireEvent.pointerDown(dragButton.parentElement as HTMLElement, { button: 0, clientX: 48, clientY: 36 });
1113
+
1114
+ expect(dragActivityListener).toHaveBeenCalledWith(
1115
+ expect.objectContaining({
1116
+ detail: expect.objectContaining({ active: true, modelUid: childInstanceId }),
1117
+ }),
1118
+ );
1119
+ dragButton.ownerDocument.removeEventListener(TOOLBAR_DRAG_ACTIVITY_EVENT, dragActivityListener);
1120
+
1121
+ vi.useFakeTimers();
1122
+ const querySelectorAllSpy = vi.spyOn(parentGap.ownerDocument, 'querySelectorAll');
1123
+
1124
+ try {
1125
+ fireEvent.mouseLeave(childIcons, { relatedTarget: parentGap });
1126
+ fireEvent.mouseEnter(parentHost, { relatedTarget: childIcons });
1127
+ fireEvent.mouseEnter(parentGap, { relatedTarget: childIcons });
1128
+ fireEvent.mouseMove(parentGap);
1129
+
1130
+ expect(querySelectorAllSpy).not.toHaveBeenCalled();
1131
+
1132
+ await act(async () => {
1133
+ await vi.advanceTimersByTimeAsync(250);
1134
+ });
1135
+ } finally {
1136
+ querySelectorAllSpy.mockRestore();
1137
+ vi.useRealTimers();
1138
+ }
1139
+
1140
+ await waitFor(() => {
1141
+ const childOverlayAfterDrag = queryOverlay(appContainer, childInstanceId);
1142
+ expect(childOverlayAfterDrag).toBeTruthy();
1143
+ expect(childOverlayAfterDrag?.className).toContain('nb-toolbar-visible');
1144
+ expect(queryOverlay(appContainer, 'parent-forked-drag-model')).toBeNull();
1145
+ });
1146
+ });
778
1147
  });
@@ -47,6 +47,25 @@ const getToolbarModelUidFromTarget = (target: EventTarget | null): string | null
47
47
  return target.closest('.nb-toolbar-container[data-model-uid]')?.getAttribute('data-model-uid') || null;
48
48
  };
49
49
 
50
+ const escapeCssAttributeValue = (value: string): string => {
51
+ return value
52
+ .replace(/\\/g, '\\\\')
53
+ .replace(/"/g, '\\"')
54
+ .replace(/\n/g, '\\a ')
55
+ .replace(/\r/g, '\\d ')
56
+ .replace(/\f/g, '\\c ');
57
+ };
58
+
59
+ const isToolbarModelUidRendered = (ownerDocument: Document | null, modelUid: string): boolean => {
60
+ if (!ownerDocument) {
61
+ return false;
62
+ }
63
+
64
+ return !!ownerDocument.querySelector<HTMLElement>(
65
+ `.nb-toolbar-container[data-model-uid="${escapeCssAttributeValue(modelUid)}"]`,
66
+ );
67
+ };
68
+
50
69
  const isNodeWithinDescendantFloatToolbar = (
51
70
  target: EventTarget | null,
52
71
  container: HTMLElement | null,
@@ -264,6 +283,15 @@ export const useFloatToolbarVisibility = ({
264
283
 
265
284
  if (isCurrentHostTarget) {
266
285
  clearHideToolbarTimer();
286
+ setActiveChildToolbarIds((prevIds) => {
287
+ if (!prevIds.length) {
288
+ return prevIds;
289
+ }
290
+
291
+ const ownerDocument = containerRef.current?.ownerDocument ?? null;
292
+ const nextIds = prevIds.filter((id) => isToolbarModelUidRendered(ownerDocument, id));
293
+ return nextIds.length === prevIds.length ? prevIds : nextIds;
294
+ });
267
295
  setHostHovered(true);
268
296
  }
269
297
 
@@ -3139,6 +3139,37 @@ class BaseFlowModelContext extends BaseFlowEngineContext {
3139
3139
  declare makeResource: <T extends FlowResource = FlowResource>(resourceType: ResourceType<T>) => T;
3140
3140
  }
3141
3141
 
3142
+ const OPEN_VIEW_INHERITED_INPUT_ARG_KEYS = [
3143
+ 'dataSourceKey',
3144
+ 'collectionName',
3145
+ 'associationName',
3146
+ 'filterByTk',
3147
+ 'sourceId',
3148
+ 'tabUid',
3149
+ ];
3150
+
3151
+ function pickDefinedKeys(source: Record<string, unknown> | null | undefined, keys: string[]) {
3152
+ const res: Record<string, unknown> = {};
3153
+ for (const key of keys) {
3154
+ if (typeof source?.[key] !== 'undefined') {
3155
+ res[key] = source[key];
3156
+ }
3157
+ }
3158
+ return res;
3159
+ }
3160
+
3161
+ function pickDefinedOpenViewInputArgs(source?: Record<string, unknown> | null) {
3162
+ return pickDefinedKeys(source, OPEN_VIEW_INHERITED_INPUT_ARG_KEYS);
3163
+ }
3164
+
3165
+ function applyDefinedDefaults(target: Record<string, unknown>, defaults: Record<string, unknown>) {
3166
+ for (const [key, value] of Object.entries(defaults)) {
3167
+ if (typeof target[key] === 'undefined') {
3168
+ target[key] = value;
3169
+ }
3170
+ }
3171
+ }
3172
+
3142
3173
  export class FlowEngineContext extends BaseFlowEngineContext {
3143
3174
  // public dataSourceManager: DataSourceManager;
3144
3175
  constructor(public engine: FlowEngine) {
@@ -3398,7 +3429,19 @@ export class FlowEngineContext extends BaseFlowEngineContext {
3398
3429
  }),
3399
3430
  });
3400
3431
  this.defineProperty('role', {
3401
- get: () => this.api?.auth?.role,
3432
+ get: () => {
3433
+ const currentRole = this.api?.auth?.role;
3434
+ if (currentRole !== '__union__') {
3435
+ return currentRole;
3436
+ }
3437
+ const roles = this.user?.roles;
3438
+ if (!Array.isArray(roles)) {
3439
+ return [];
3440
+ }
3441
+ return roles
3442
+ .map((role: { name?: string }) => role?.name)
3443
+ .filter((name: string | undefined): name is string => !!name);
3444
+ },
3402
3445
  cache: false,
3403
3446
  // 注意:使用惰性 meta 工厂,避免在 i18n 尚未注入时提前求值导致无法翻译
3404
3447
  meta: Object.assign(() => ({ type: 'string', title: this.t('Current role'), sort: 990 }), {
@@ -3561,7 +3604,17 @@ export class FlowEngineContext extends BaseFlowEngineContext {
3561
3604
  doc = {};
3562
3605
  }
3563
3606
  const deprecatedCtx = createRunJSDeprecationProxy(runCtx, { doc });
3564
- const globals: Record<string, any> = { ctx: deprecatedCtx, ...(options?.globals || {}) };
3607
+ const browserGlobals: Record<string, any> = {};
3608
+ if (typeof window !== 'undefined') {
3609
+ browserGlobals.window = window;
3610
+ if (typeof navigator !== 'undefined') {
3611
+ browserGlobals.navigator = navigator;
3612
+ }
3613
+ }
3614
+ if (typeof document !== 'undefined') {
3615
+ browserGlobals.document = document;
3616
+ }
3617
+ const globals: Record<string, any> = { ctx: deprecatedCtx, ...browserGlobals, ...(options?.globals || {}) };
3565
3618
  const { timeoutMs } = options || {};
3566
3619
  return new JSRunner({ globals, timeoutMs });
3567
3620
  });
@@ -3687,7 +3740,14 @@ export class FlowModelContext extends BaseFlowModelContext {
3687
3740
  },
3688
3741
  });
3689
3742
  this.defineMethod('openView', async function (uid: string, options) {
3690
- const opts = { ...options };
3743
+ const inheritedInputArgs = {
3744
+ ...(typeof this.model?.['getInputArgs'] === 'function'
3745
+ ? pickDefinedOpenViewInputArgs(this.model['getInputArgs']())
3746
+ : {}),
3747
+ ...pickDefinedOpenViewInputArgs(this.inputArgs),
3748
+ };
3749
+ const opts = { ...(options || {}) };
3750
+ applyDefinedDefaults(opts, inheritedInputArgs);
3691
3751
  // NOTE: when custom context is passed, route navigation must be disabled to avoid losing it after refresh.
3692
3752
  if (opts.defineProperties || opts.defineMethods) {
3693
3753
  opts.navigation = false; // 强制不使用路由导航, 避免刷新页面时丢失上下文
@@ -3695,15 +3755,6 @@ export class FlowModelContext extends BaseFlowModelContext {
3695
3755
  let model: FlowModel | null = null;
3696
3756
  model = await this.engine.loadModel({ uid });
3697
3757
  if (!model) {
3698
- const pickDefined = (src: Record<string, any>, keys: string[]) => {
3699
- const res: Record<string, any> = {};
3700
- for (const k of keys) {
3701
- if (typeof src?.[k] !== 'undefined') {
3702
- res[k] = src[k];
3703
- }
3704
- }
3705
- return res;
3706
- };
3707
3758
  model = this.engine.createModel({
3708
3759
  uid, // 注意: 新建的 model 应该使用 ${parentModel.uid}-xxx 形式的 uid
3709
3760
  use: 'PopupActionModel',
@@ -3714,7 +3765,7 @@ export class FlowModelContext extends BaseFlowModelContext {
3714
3765
  popupSettings: {
3715
3766
  openView: {
3716
3767
  // 仅在创建时持久化一份默认配置;运行时以本次 opts 为准,避免多个 opener 互相覆盖。
3717
- ...pickDefined(opts, ['dataSourceKey', 'collectionName', 'associationName', 'mode', 'size']),
3768
+ ...pickDefinedKeys(opts, ['dataSourceKey', 'collectionName', 'associationName', 'mode', 'size']),
3718
3769
  },
3719
3770
  },
3720
3771
  },
@@ -3734,8 +3785,6 @@ export class FlowModelContext extends BaseFlowModelContext {
3734
3785
  // 统一语义:为即将打开的外部视图定义一个 PendingView(占位视图)
3735
3786
  const pendingType = (opts?.isMobileLayout ? 'embed' : opts?.mode || 'drawer') as any;
3736
3787
  const pendingInputArgs = { ...opts, viewUid, navigation: opts.navigation };
3737
- pendingInputArgs.filterByTk = pendingInputArgs.filterByTk || this.inputArgs?.filterByTk;
3738
- pendingInputArgs.sourceId = pendingInputArgs.sourceId || this.inputArgs?.sourceId;
3739
3788
 
3740
3789
  const pendingView = {
3741
3790
  type: pendingType,
@@ -3754,17 +3803,10 @@ export class FlowModelContext extends BaseFlowModelContext {
3754
3803
  } else if (on && typeof on === 'object' && typeof (on as any).eventName === 'string' && (on as any).eventName) {
3755
3804
  openEventName = (on as any).eventName;
3756
3805
  }
3757
- await model.dispatchEvent(
3758
- openEventName,
3759
- {
3760
- // navigation: false, // TODO: 路由模式有bug,不支持多层同样viewId的弹窗,因此这里默认先用false
3761
- // ...this.model?.['getInputArgs']?.(), // 避免部分关系字段信息丢失, 仿照 ClickableCollectionField 做法
3762
- ...opts,
3763
- },
3764
- {
3765
- debounce: true,
3766
- },
3767
- );
3806
+ await model.dispatchEvent(openEventName, {
3807
+ // navigation: false, // TODO: 路由模式有bug,不支持多层同样viewId的弹窗,因此这里默认先用false
3808
+ ...opts,
3809
+ });
3768
3810
  });
3769
3811
  this.defineMethod('getEvents', function (this: BaseFlowModelContext) {
3770
3812
  return this.model.getEvents();
@@ -46,12 +46,17 @@ export function createJSRunnerWithVersion(this: FlowContext, options?: JSRunnerO
46
46
  doc = {};
47
47
  }
48
48
  const deprecatedCtx = createRunJSDeprecationProxy(runCtx, { doc });
49
- const globals: Record<string, any> = { ctx: deprecatedCtx, ...(options?.globals || {}) };
50
- // 对字段/区块类上下文,默认注入 window/document 以支持在沙箱中访问 DOM API
51
- if (modelClass === 'JSFieldModel' || modelClass === 'JSBlockModel') {
52
- if (typeof window !== 'undefined') globals.window = window as any;
53
- if (typeof document !== 'undefined') globals.document = document as any;
49
+ const browserGlobals: Record<string, any> = {};
50
+ if (typeof window !== 'undefined') {
51
+ browserGlobals.window = window;
52
+ if (typeof navigator !== 'undefined') {
53
+ browserGlobals.navigator = navigator;
54
+ }
54
55
  }
56
+ if (typeof document !== 'undefined') {
57
+ browserGlobals.document = document;
58
+ }
59
+ const globals: Record<string, any> = { ctx: deprecatedCtx, ...browserGlobals, ...(options?.globals || {}) };
55
60
  // 透传 JSRunnerOptions 其余配置(如 timeoutMs)
56
61
  const { timeoutMs } = options || {};
57
62
  return new JSRunner({ globals, timeoutMs });
@@ -59,7 +64,8 @@ export function createJSRunnerWithVersion(this: FlowContext, options?: JSRunnerO
59
64
 
60
65
  export function getRunJSScenesForModel(modelClass: string, version: RunJSVersion = 'v1'): string[] {
61
66
  const meta = RunJSContextRegistry.getMeta(version, modelClass);
62
- return Array.isArray(meta?.scenes) ? [...meta!.scenes!] : [];
67
+ const scenes = meta?.scenes;
68
+ return Array.isArray(scenes) ? [...scenes] : [];
63
69
  }
64
70
 
65
71
  export function getRunJSScenesForContext(ctx: FlowContext, { version = 'v1' as RunJSVersion } = {}): string[] {