@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,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