diff --git a/apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx b/apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx index 6b1a4af417..117080968b 100644 --- a/apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx +++ b/apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx @@ -43,6 +43,7 @@ function SpreadsheetEditor({ note, noteContext, readOnly }: TypeWidgetProps & { useDarkMode(apiRef); usePersistence(note, noteContext, apiRef, containerRef, readOnly); useSearchIntegration(apiRef); + useFixRadixPortals(); // Focus the spreadsheet when the note is focused. useTriliumEvent("focusOnDetail", () => { @@ -55,6 +56,53 @@ function SpreadsheetEditor({ note, noteContext, readOnly }: TypeWidgetProps & { return
; } +/** + * Univer's design system uses Radix UI primitives whose DismissableLayer detects + * "outside" clicks/focus via document-level pointerdown/focusin listeners combined + * with a React capture-phase flag. In React, portal events bubble through the + * component tree so onPointerDownCapture fires on the DismissableLayer, setting an + * internal flag that suppresses the "outside" detection. With preact/compat, portal + * events don't bubble through the React tree, so the flag never gets set and Radix + * immediately dismisses popups. + * + * Radix dispatches cancelable custom events ("dismissableLayer.pointerDownOutside" + * and "dismissableLayer.focusOutside") on the original event target before calling + * onDismiss. The dismiss is skipped if defaultPrevented is true. This hook intercepts + * those custom events in the capture phase and prevents default when the target is + * inside a Radix portal, restoring the expected behavior. + */ +function useFixRadixPortals() { + useEffect(() => { + function isInsideUniverPortal(target: HTMLElement): boolean { + // Check for Radix content wrappers (covers most popover/menu portals). + if (target.closest("[data-radix-popper-content-wrapper]") || target.closest("[data-radix-menu-content]")) { + return true; + } + + // Some Univer portals (e.g. nested color picker) don't use a Radix + // wrapper. Fall back to checking if the element has Univer classes. + if (target.className?.includes("univer-")) { + return true; + } + + return false; + } + + function preventDismiss(e: Event) { + if (e.target instanceof HTMLElement && isInsideUniverPortal(e.target)) { + e.preventDefault(); + } + } + + document.addEventListener("dismissableLayer.pointerDownOutside", preventDismiss, true); + document.addEventListener("dismissableLayer.focusOutside", preventDismiss, true); + return () => { + document.removeEventListener("dismissableLayer.pointerDownOutside", preventDismiss, true); + document.removeEventListener("dismissableLayer.focusOutside", preventDismiss, true); + }; + }, []); +} + function useInitializeSpreadsheet(containerRef: MutableRef