🐛 fix(overlay): 修复浮窗与弹层层级遮挡

- 统一分离窗口、弹出层和弹窗的层级常量
- 将浮动窗口通过 Portal 挂载到 body 并隔离 AntD 弹层
- 补充右键菜单和分离窗口层级回归测试
This commit is contained in:
Syngnat
2026-07-23 08:45:48 +08:00
parent 5efe6e7947
commit 818b75b691
14 changed files with 133 additions and 37 deletions

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { createPortal } from 'react-dom';
import { CopyOutlined, EditOutlined, UndoOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
import { t } from '../i18n';
import { APP_POPUP_Z_INDEX } from '../utils/overlayZIndex';
interface CellContextMenuState {
visible: boolean;
@@ -125,7 +126,7 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
position: 'fixed',
left: cellContextMenu.x,
top: cellContextMenu.y,
zIndex: 10000,
zIndex: APP_POPUP_Z_INDEX,
background: bgContextMenu,
border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9',
borderRadius: 4,

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Button, message } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import { createPortal } from 'react-dom';
import { APP_POPUP_Z_INDEX } from '../utils/overlayZIndex';
import Modal from './common/ResizableDraggableModal';
import ImportPreviewModal from './ImportPreviewModal';
@@ -882,7 +883,7 @@ const renderDataTableView = () => (
position: 'fixed',
left: cellContextMenu.x,
top: cellContextMenu.y,
zIndex: 10000,
zIndex: APP_POPUP_Z_INDEX,
}}
onClick={(e) => e.stopPropagation()}
>

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { Button, Spin } from 'antd';
import { createPortal } from 'react-dom';
import { Button, ConfigProvider, Spin } from 'antd';
import { useStore } from '../store';
import { t } from '../i18n';
import {
@@ -10,6 +11,7 @@ import {
} from '../utils/detachedWindow';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { hasNativeDetachedWindowManager } from '../utils/nativeDetachedWindowHost';
import { APP_POPUP_Z_INDEX } from '../utils/overlayZIndex';
import { useManagedPointerInteraction } from '../hooks/useManagedPointerInteraction';
import AIPanelErrorBoundary from './ai/AIPanelErrorBoundary';
@@ -129,8 +131,9 @@ const FloatingAIChatWindow: React.FC<FloatingAIChatWindowProps> = ({
const isDark = theme === 'dark';
const bounds = windowState;
return (
<div className="gn-detached-ai-chat-layer" aria-label={t('ai_chat.detached.window_aria')}>
const floatingWindow = (
<ConfigProvider theme={{ token: { zIndexPopupBase: APP_POPUP_Z_INDEX } }}>
<div className="gn-detached-ai-chat-layer" aria-label={t('ai_chat.detached.window_aria')}>
<style>{`
.gn-detached-ai-chat-layer {
position: fixed;
@@ -293,8 +296,13 @@ const FloatingAIChatWindow: React.FC<FloatingAIChatWindowProps> = ({
onPointerDown={(event) => startInteraction(event, 'resize-se', bounds)}
/>
</div>
</div>
</div>
</ConfigProvider>
);
return typeof document === 'undefined'
? floatingWindow
: createPortal(floatingWindow, document.body);
};
export default FloatingAIChatWindow;

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Button, Tooltip } from 'antd';
import { createPortal } from 'react-dom';
import { Button, ConfigProvider, Tooltip } from 'antd';
import { CloseOutlined, CompressOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { t } from '../i18n';
@@ -12,6 +13,10 @@ import {
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
DETACHED_WINDOW_VIEWPORT_PADDING,
} from '../utils/detachedWindow';
import {
APP_DETACHED_WINDOW_Z_INDEX_BASE,
APP_POPUP_Z_INDEX,
} from '../utils/overlayZIndex';
import { useManagedPointerInteraction } from '../hooks/useManagedPointerInteraction';
const createLazyDetachedResultDataGrid = () => React.lazy(() => import('./DataGrid'));
@@ -148,14 +153,15 @@ const FloatingQueryResultWindows: React.FC = () => {
const isDark = theme === 'dark';
return (
<div className="gn-detached-result-layer" aria-label={t('query_editor.results_panel.detached.title', { index: '' })}>
const floatingWindows = (
<ConfigProvider theme={{ token: { zIndexPopupBase: APP_POPUP_Z_INDEX } }}>
<div className="gn-detached-result-layer" aria-label={t('query_editor.results_panel.detached.title', { index: '' })}>
<style>{`
.gn-detached-result-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1210;
z-index: ${APP_DETACHED_WINDOW_Z_INDEX_BASE};
}
.gn-detached-result-window {
position: fixed;
@@ -343,8 +349,13 @@ const FloatingQueryResultWindows: React.FC = () => {
</div>
);
})}
</div>
</div>
</ConfigProvider>
);
return typeof document === 'undefined'
? floatingWindows
: createPortal(floatingWindows, document.body);
};
export default FloatingQueryResultWindows;

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { Button, Tooltip } from 'antd';
import { createPortal } from 'react-dom';
import { Button, ConfigProvider, Tooltip } from 'antd';
import { CloseOutlined, CompressOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { t } from '../i18n';
@@ -18,6 +19,10 @@ import WorkbenchTabContent from './WorkbenchTabContent';
import { hasNativeDetachedWindowManager } from '../utils/nativeDetachedWindowHost';
import { useWorkbenchTabs } from '../hooks/useWorkbenchTabs';
import { useManagedPointerInteraction } from '../hooks/useManagedPointerInteraction';
import {
APP_DETACHED_WINDOW_Z_INDEX_BASE,
APP_POPUP_Z_INDEX,
} from '../utils/overlayZIndex';
const getTabKindLabel = (type: string): string => {
if (type === 'query') return t('tab_manager.kind_badge.query');
@@ -184,14 +189,15 @@ const FloatingWorkbenchWindows: React.FC = () => {
const isDark = theme === 'dark';
return (
<div className="gn-detached-window-layer" aria-label={t('tab_manager.detached.title_fallback')}>
const floatingWindows = (
<ConfigProvider theme={{ token: { zIndexPopupBase: APP_POPUP_Z_INDEX } }}>
<div className="gn-detached-window-layer" aria-label={t('tab_manager.detached.title_fallback')}>
<style>{`
.gn-detached-window-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1200;
z-index: ${APP_DETACHED_WINDOW_Z_INDEX_BASE};
}
.gn-detached-window {
position: fixed;
@@ -368,8 +374,13 @@ const FloatingWorkbenchWindows: React.FC = () => {
/>
</div>
))}
</div>
</div>
</ConfigProvider>
);
return typeof document === 'undefined'
? floatingWindows
: createPortal(floatingWindows, document.body);
};
export default FloatingWorkbenchWindows;

View File

@@ -71,6 +71,7 @@ export {
import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react';
import { createPortal } from 'react-dom';
import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Popover, Radio, Select, Tooltip } from 'antd';
import { APP_POPUP_Z_INDEX } from '../utils/overlayZIndex';
import {
CaretDownFilled,
DatabaseOutlined,
@@ -3369,7 +3370,7 @@ const Sidebar: React.FC<{
position: 'fixed',
left: contextMenu.x,
top: contextMenu.y,
zIndex: 10000,
zIndex: APP_POPUP_Z_INDEX,
width: contextMenu.overlayStyle?.width ?? SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH,
maxWidth: contextMenu.overlayStyle?.maxWidth ?? 'calc(100vw - 24px)',
['--gn-v2-context-menu-max-height' as any]: `${contextMenu.maxHeight ?? SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT}px`,

View File

@@ -31,6 +31,7 @@ import { extractTableNameFromMetadataRow } from '../utils/tableMetadataRows';
import { V2TableContextMenuView, type V2TableContextMenuActionKey } from './V2TableContextMenu';
import { showSQLExportOptionsDialog } from './SQLExportOptionsDialog';
import { confirmCopyTable } from './tableCopyAction';
import { APP_POPUP_Z_INDEX } from '../utils/overlayZIndex';
interface TableOverviewProps {
tab: TabData;
@@ -1606,7 +1607,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
position: 'fixed',
left: v2ContextMenu.x,
top: v2ContextMenu.y,
zIndex: 10000,
zIndex: APP_POPUP_Z_INDEX,
width: OVERVIEW_CONTEXT_MENU_WIDTH,
maxWidth: 'calc(100vw - 24px)',
['--gn-v2-context-menu-max-height' as any]: `${v2ContextMenu.maxHeight}px`,

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button, Drawer, Empty, Pagination, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import { Button, ConfigProvider, Drawer, Empty, Pagination, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CloseOutlined,
@@ -36,6 +36,10 @@ import {
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
DETACHED_WINDOW_VIEWPORT_PADDING,
} from '../../utils/detachedWindow';
import {
APP_DETACHED_WINDOW_Z_INDEX_BASE,
APP_POPUP_Z_INDEX,
} from '../../utils/overlayZIndex';
import { useManagedPointerInteraction } from '../../hooks/useManagedPointerInteraction';
import {
loadResultDiffDetachedBoundsMemory,
@@ -79,7 +83,7 @@ type FloatingBounds = {
zIndex: number;
};
const initialFloatingBounds = (zIndex = 1320): FloatingBounds =>
const initialFloatingBounds = (zIndex = APP_DETACHED_WINDOW_Z_INDEX_BASE + 1): FloatingBounds =>
resolveResultDiffDetachedBounds(loadResultDiffDetachedBoundsMemory(), zIndex);
const ResultDiffPanel: React.FC<ResultDiffPanelProps> = ({
@@ -707,13 +711,14 @@ const ResultDiffPanel: React.FC<ResultDiffPanelProps> = ({
const floatingWindow = detached && typeof document !== 'undefined'
? createPortal(
<div className="gn-result-diff-floating-layer" aria-label={titleText}>
<ConfigProvider theme={{ token: { zIndexPopupBase: APP_POPUP_Z_INDEX } }}>
<div className="gn-result-diff-floating-layer" aria-label={titleText}>
<style>{`
.gn-result-diff-floating-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1320;
z-index: ${APP_DETACHED_WINDOW_Z_INDEX_BASE};
}
.gn-result-diff-floating-window {
position: fixed;
@@ -782,7 +787,10 @@ const ResultDiffPanel: React.FC<ResultDiffPanelProps> = ({
height: bounds.height,
zIndex: bounds.zIndex,
}}
onMouseDown={() => setBounds((prev) => ({ ...prev, zIndex: Math.max(prev.zIndex, 1320) + 1 }))}
onMouseDown={() => setBounds((prev) => ({
...prev,
zIndex: Math.max(prev.zIndex, APP_DETACHED_WINDOW_Z_INDEX_BASE) + 1,
}))}
>
<div
className="gn-result-diff-floating-header"
@@ -811,7 +819,8 @@ const ResultDiffPanel: React.FC<ResultDiffPanelProps> = ({
onPointerDown={(event) => startFloatingDrag(event, 'resize-se')}
/>
</div>
</div>,
</div>
</ConfigProvider>,
document.body,
)
: null;

View File

@@ -13,11 +13,19 @@ import {
shouldDetachAtScreenPoint,
toAIChatDetachedBoundsMemory,
} from './detachedWindow';
import {
APP_DETACHED_WINDOW_Z_INDEX_BASE,
APP_POPUP_Z_INDEX,
} from './overlayZIndex';
describe('detachedWindow helpers', () => {
it('computes next z-index above existing windows', () => {
expect(nextDetachedZIndex([])).toBe(1201);
expect(nextDetachedZIndex([{ zIndex: 1300 }, { zIndex: 1250 }])).toBe(1301);
expect(nextDetachedZIndex([])).toBe(APP_DETACHED_WINDOW_Z_INDEX_BASE + 1);
expect(nextDetachedZIndex([
{ zIndex: APP_DETACHED_WINDOW_Z_INDEX_BASE + 100 },
{ zIndex: APP_DETACHED_WINDOW_Z_INDEX_BASE + 50 },
])).toBe(APP_DETACHED_WINDOW_Z_INDEX_BASE + 101);
expect(nextDetachedZIndex([])).toBeLessThan(APP_POPUP_Z_INDEX);
});
it('detaches only when vertical drag exceeds threshold', () => {
@@ -30,7 +38,8 @@ describe('detachedWindow helpers', () => {
const bounds = createDefaultDetachedBounds([]);
expect(bounds.width).toBeGreaterThan(0);
expect(bounds.height).toBeGreaterThan(0);
expect(bounds.zIndex).toBeGreaterThan(1200);
expect(bounds.zIndex).toBeGreaterThan(APP_DETACHED_WINDOW_Z_INDEX_BASE);
expect(bounds.zIndex).toBeLessThan(APP_POPUP_Z_INDEX);
});
it('resolves floating window titles with object labels', () => {

View File

@@ -1,3 +1,5 @@
import { APP_DETACHED_WINDOW_Z_INDEX_BASE } from './overlayZIndex';
export type DetachedWindowBounds = {
x: number;
y: number;
@@ -86,7 +88,7 @@ export const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
export const nextDetachedZIndex = (windows: Array<{ zIndex?: number }>): number => {
let max = 1200;
let max = APP_DETACHED_WINDOW_Z_INDEX_BASE;
for (const windowState of windows) {
const z = Number(windowState?.zIndex);
if (Number.isFinite(z) && z > max) {

View File

@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
APP_COMMAND_PALETTE_Z_INDEX,
APP_DETACHED_WINDOW_Z_INDEX_BASE,
APP_FOREGROUND_MODAL_Z_INDEX,
APP_NESTED_MODAL_Z_INDEX,
APP_OVERLAY_Z_INDEX_BASE,
@@ -28,6 +29,14 @@ const connectionModalSource = readSource('../components/ConnectionModal.tsx');
const redisViewerSource = readSource('../components/RedisViewer.tsx');
const resultDiffPanelSource = readSource('../components/resultDiff/ResultDiffPanel.tsx');
const sidebarSearchPanelSource = readSource('../components/sidebar/SidebarSearchPanel.tsx');
const floatingAIChatSource = readSource('../components/FloatingAIChatWindow.tsx');
const floatingWorkbenchSource = readSource('../components/FloatingWorkbenchWindows.tsx');
const floatingQueryResultSource = readSource('../components/FloatingQueryResultWindows.tsx');
const detachedWindowSource = readSource('./detachedWindow.ts');
const legacyGridContextMenuSource = readSource('../components/DataGridLegacyCellContextMenu.tsx');
const dataGridShellSource = readSource('../components/DataGridShell.tsx');
const sidebarSource = readSource('../components/Sidebar.tsx');
const tableOverviewSource = readSource('../components/TableOverview.tsx');
const v2ThemeSource = readSource('../v2-theme.css');
const collectRuntimeSources = (directory: string): string[] => readdirSync(directory).flatMap((entry) => {
@@ -94,9 +103,10 @@ afterEach(() => {
});
describe('application overlay z-index policy', () => {
it('keeps root, popup, and foreground modal layers ordered', () => {
it('keeps root dialogs, floating windows, popups, and foreground dialogs ordered', () => {
expect(APP_OVERLAY_Z_INDEX_BASE).toBe(10_000);
expect(APP_POPUP_Z_INDEX).toBeGreaterThan(APP_OVERLAY_Z_INDEX_BASE);
expect(APP_DETACHED_WINDOW_Z_INDEX_BASE).toBeGreaterThan(APP_OVERLAY_Z_INDEX_BASE);
expect(APP_POPUP_Z_INDEX).toBeGreaterThan(APP_DETACHED_WINDOW_Z_INDEX_BASE);
expect(APP_FOREGROUND_MODAL_Z_INDEX).toBeGreaterThan(APP_POPUP_Z_INDEX);
expect(APP_NESTED_MODAL_Z_INDEX).toBeGreaterThan(APP_FOREGROUND_MODAL_Z_INDEX);
expect(APP_COMMAND_PALETTE_Z_INDEX).toBeGreaterThan(APP_NESTED_MODAL_Z_INDEX);
@@ -110,7 +120,7 @@ describe('application overlay z-index policy', () => {
expect(configSpy).toHaveBeenCalledWith({
theme: {
token: {
zIndexPopupBase: APP_OVERLAY_Z_INDEX_BASE,
zIndexPopupBase: APP_POPUP_Z_INDEX,
},
},
});
@@ -168,6 +178,34 @@ describe('application overlay z-index policy', () => {
expect(resultDiffPanelSource).not.toContain('zIndex: 10050');
});
it('keeps every in-WebView floating window above root dialogs with usable portaled popups', () => {
expect(detachedWindowSource).toContain('let max = APP_DETACHED_WINDOW_Z_INDEX_BASE;');
for (const source of [
floatingAIChatSource,
floatingWorkbenchSource,
floatingQueryResultSource,
]) {
expect(source).toContain("import { createPortal } from 'react-dom';");
expect(source).toContain('zIndexPopupBase: APP_POPUP_Z_INDEX');
expect(source).toContain('document.body');
}
expect(floatingWorkbenchSource).toContain('z-index: ${APP_DETACHED_WINDOW_Z_INDEX_BASE};');
expect(floatingQueryResultSource).toContain('z-index: ${APP_DETACHED_WINDOW_Z_INDEX_BASE};');
expect(resultDiffPanelSource).toContain('z-index: ${APP_DETACHED_WINDOW_Z_INDEX_BASE};');
expect(resultDiffPanelSource).toContain('zIndexPopupBase: APP_POPUP_Z_INDEX');
});
it('routes body-level context menus through the shared popup layer', () => {
for (const source of [
legacyGridContextMenuSource,
dataGridShellSource,
sidebarSource,
tableOverviewSource,
]) {
expect(source).toContain('zIndex: APP_POPUP_Z_INDEX');
}
});
it('portals the command palette above modal stacking contexts', () => {
expect(sidebarSearchPanelSource).toContain("import { createPortal } from 'react-dom';");
expect(sidebarSearchPanelSource).toContain('style={{ zIndex: APP_COMMAND_PALETTE_Z_INDEX }}');

View File

@@ -1,16 +1,17 @@
import { ConfigProvider } from 'antd';
export const APP_OVERLAY_Z_INDEX_BASE = 10_000;
export const APP_POPUP_Z_INDEX = APP_OVERLAY_Z_INDEX_BASE + 50;
export const APP_FOREGROUND_MODAL_Z_INDEX = APP_OVERLAY_Z_INDEX_BASE + 100;
export const APP_NESTED_MODAL_Z_INDEX = APP_FOREGROUND_MODAL_Z_INDEX + 100;
export const APP_COMMAND_PALETTE_Z_INDEX = APP_NESTED_MODAL_Z_INDEX + 100;
export const APP_DETACHED_WINDOW_Z_INDEX_BASE = APP_OVERLAY_Z_INDEX_BASE + 1_000;
export const APP_POPUP_Z_INDEX = APP_OVERLAY_Z_INDEX_BASE + 9_000;
export const APP_FOREGROUND_MODAL_Z_INDEX = APP_OVERLAY_Z_INDEX_BASE + 10_000;
export const APP_NESTED_MODAL_Z_INDEX = APP_FOREGROUND_MODAL_Z_INDEX + 1_000;
export const APP_COMMAND_PALETTE_Z_INDEX = APP_NESTED_MODAL_Z_INDEX + 1_000;
export const configureAntdStaticOverlayLayer = (): void => {
ConfigProvider.config({
theme: {
token: {
zIndexPopupBase: APP_OVERLAY_Z_INDEX_BASE,
zIndexPopupBase: APP_POPUP_Z_INDEX,
},
},
});

View File

@@ -4,6 +4,7 @@ import {
resolveResultDiffDetachedBounds,
saveResultDiffDetachedBoundsMemory,
} from './detachedBoundsMemory';
import { APP_DETACHED_WINDOW_Z_INDEX_BASE } from '../overlayZIndex';
describe('resultDiff detachedBoundsMemory', () => {
beforeEach(() => {
@@ -46,5 +47,6 @@ describe('resultDiff detachedBoundsMemory', () => {
const resolved = resolveResultDiffDetachedBounds(null);
expect(resolved.width).toBeGreaterThan(0);
expect(resolved.height).toBeGreaterThan(0);
expect(resolved.zIndex).toBe(APP_DETACHED_WINDOW_Z_INDEX_BASE + 1);
});
});

View File

@@ -6,6 +6,7 @@ import {
DEFAULT_DETACHED_WINDOW_WIDTH,
DETACHED_WINDOW_VIEWPORT_PADDING,
} from '../detachedWindow';
import { APP_DETACHED_WINDOW_Z_INDEX_BASE } from '../overlayZIndex';
const STORAGE_KEY = 'gonavi.resultDiff.detachedBounds.v1';
@@ -62,7 +63,7 @@ export const saveResultDiffDetachedBoundsMemory = (
/** 按当前视口校正记忆的位置与尺寸,避免移出屏幕 */
export const resolveResultDiffDetachedBounds = (
memory?: ResultDiffDetachedBoundsMemory | null,
zIndex = 1320,
zIndex = APP_DETACHED_WINDOW_Z_INDEX_BASE + 1,
): {
x: number;
y: number;