@nocobase/flow-engine 2.2.0-beta.6 → 2.2.0-beta.8

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 (51) hide show
  1. package/lib/components/MobilePopup.style.js +16 -5
  2. package/lib/components/dnd/index.js +9 -2
  3. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
  4. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
  5. package/lib/flowContext.d.ts +1 -1
  6. package/lib/flowContext.js +32 -8
  7. package/lib/locale/en-US.json +1 -0
  8. package/lib/locale/index.d.ts +2 -0
  9. package/lib/locale/zh-CN.json +1 -0
  10. package/lib/resources/apiResource.js +2 -1
  11. package/lib/resources/baseRecordResource.js +6 -17
  12. package/lib/resources/multiRecordResource.js +13 -3
  13. package/lib/resources/singleRecordResource.js +7 -2
  14. package/lib/utils/dataSourceDirty.d.ts +20 -0
  15. package/lib/utils/dataSourceDirty.js +139 -0
  16. package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
  17. package/lib/utils/dirtyAwareApiClient.js +378 -0
  18. package/lib/utils/index.d.ts +1 -0
  19. package/lib/utils/index.js +11 -0
  20. package/lib/utils/openViewRouteState.d.ts +28 -0
  21. package/lib/utils/openViewRouteState.js +125 -0
  22. package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
  23. package/lib/utils/parsePathnameToViewParams.js +18 -1
  24. package/lib/views/ViewNavigation.js +5 -0
  25. package/package.json +4 -4
  26. package/src/__tests__/flowContext.test.ts +131 -0
  27. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
  28. package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
  29. package/src/components/MobilePopup.style.ts +22 -6
  30. package/src/components/__tests__/MobilePopup.style.test.tsx +103 -0
  31. package/src/components/dnd/index.tsx +11 -2
  32. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
  33. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
  34. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
  35. package/src/flowContext.ts +45 -8
  36. package/src/locale/en-US.json +1 -0
  37. package/src/locale/zh-CN.json +1 -0
  38. package/src/resources/apiResource.ts +2 -1
  39. package/src/resources/baseRecordResource.ts +6 -23
  40. package/src/resources/multiRecordResource.ts +13 -3
  41. package/src/resources/singleRecordResource.ts +6 -1
  42. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +392 -0
  43. package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
  44. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
  45. package/src/utils/dataSourceDirty.ts +126 -0
  46. package/src/utils/dirtyAwareApiClient.ts +430 -0
  47. package/src/utils/index.ts +10 -0
  48. package/src/utils/openViewRouteState.ts +107 -0
  49. package/src/utils/parsePathnameToViewParams.ts +23 -1
  50. package/src/views/ViewNavigation.ts +6 -1
  51. package/src/views/__tests__/ViewNavigation.test.ts +15 -0
@@ -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,
@@ -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