From 6fb6674803ca698717cb244cf363a1de8cb30b56 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 9 Jul 2026 09:44:53 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(tab/window):=20=E5=AE=8C?= =?UTF-8?q?=E5=96=84Tab=E6=8B=96=E5=87=BA=E7=8B=AC=E7=AB=8B=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E8=B7=9F=E6=89=8B=E9=A2=84=E8=A7=88=E4=B8=8E=E5=85=A5?= =?UTF-8?q?=E5=9C=BA=E5=8A=A8=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 结果Tab支持拖出拆分,拖拽过程抑制文本选中 - 抽取 DetachDragPreview:进度条/跟手预览/阈值高亮 - 工作区主Tab拖出复用同一套预览动效,松手位置跟手 - 浮动窗增加 scale 入场动画,增强拆出反馈 --- frontend/src/components/DetachDragPreview.tsx | 202 ++++++++++++++++++ .../components/FloatingQueryResultWindows.tsx | 12 ++ .../components/FloatingWorkbenchWindows.tsx | 12 ++ frontend/src/components/QueryEditor.tsx | 9 +- .../components/QueryEditorResultsPanel.tsx | 162 +++++++++++++- frontend/src/components/TabManager.tsx | 78 ++++++- frontend/src/utils/detachedWindow.test.ts | 6 + frontend/src/utils/detachedWindow.ts | 9 + 8 files changed, 476 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/DetachDragPreview.tsx diff --git a/frontend/src/components/DetachDragPreview.tsx b/frontend/src/components/DetachDragPreview.tsx new file mode 100644 index 00000000..13cc3f7b --- /dev/null +++ b/frontend/src/components/DetachDragPreview.tsx @@ -0,0 +1,202 @@ +import React, { useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ExpandOutlined } from '@ant-design/icons'; +import { DETACH_TAB_DRAG_Y_THRESHOLD } from '../utils/detachedWindow'; + +export type DetachDragPreviewState = { + title: string; + clientX: number; + clientY: number; + willDetach: boolean; + progress: number; +}; + +export const buildDetachDragPreviewState = (params: { + title: string; + clientX: number; + clientY: number; + deltaY: number; +}): DetachDragPreviewState => { + const distance = Math.abs(params.deltaY); + const progress = Math.max(0, Math.min(1, distance / DETACH_TAB_DRAG_Y_THRESHOLD)); + return { + title: params.title, + clientX: params.clientX, + clientY: params.clientY, + willDetach: distance >= DETACH_TAB_DRAG_Y_THRESHOLD, + progress, + }; +}; + +type DetachDragPreviewProps = { + preview: DetachDragPreviewState | null; + darkMode?: boolean; + readyHint: string; +}; + +/** + * 拖出独立窗口时的跟手预览(工作区 Tab / 结果 Tab 共用)。 + */ +export const DetachDragPreview: React.FC = ({ + preview, + darkMode = false, + readyHint, +}) => { + const portal = useMemo(() => { + if (!preview || typeof document === 'undefined') { + return null; + } + const previewWidth = 200 + preview.progress * 300; + const previewHeight = 52 + preview.progress * 200; + const left = Math.max(12, preview.clientX - previewWidth * 0.28); + const top = Math.max(12, preview.clientY - 22); + const hint = preview.willDetach ? readyHint : preview.title; + return createPortal( + <> + +
+
+ + {preview.title} + {Math.round(preview.progress * 100)}% +
+
+ {hint} +
+ +
+
+
+
+ , + document.body, + ); + }, [darkMode, preview, readyHint]); + + return portal; +}; + +export default DetachDragPreview; diff --git a/frontend/src/components/FloatingQueryResultWindows.tsx b/frontend/src/components/FloatingQueryResultWindows.tsx index f4cabf0c..58ac0a80 100644 --- a/frontend/src/components/FloatingQueryResultWindows.tsx +++ b/frontend/src/components/FloatingQueryResultWindows.tsx @@ -148,6 +148,18 @@ const FloatingQueryResultWindows: React.FC = () => { : '0 18px 48px rgba(15,23,42,0.18)'}; overflow: hidden; pointer-events: auto; + animation: gn-detached-result-enter 180ms cubic-bezier(0.22, 1, 0.36, 1); + transform-origin: top left; + } + @keyframes gn-detached-result-enter { + from { + opacity: 0.55; + transform: scale(0.92); + } + to { + opacity: 1; + transform: scale(1); + } } .gn-detached-result-header { flex: 0 0 auto; diff --git a/frontend/src/components/FloatingWorkbenchWindows.tsx b/frontend/src/components/FloatingWorkbenchWindows.tsx index d334ed6a..9a6f029a 100644 --- a/frontend/src/components/FloatingWorkbenchWindows.tsx +++ b/frontend/src/components/FloatingWorkbenchWindows.tsx @@ -203,6 +203,18 @@ const FloatingWorkbenchWindows: React.FC = () => { : '0 18px 48px rgba(15,23,42,0.18)'}; overflow: hidden; pointer-events: auto; + animation: gn-detached-workbench-enter 180ms cubic-bezier(0.22, 1, 0.36, 1); + transform-origin: top left; + } + @keyframes gn-detached-workbench-enter { + from { + opacity: 0.55; + transform: scale(0.92); + } + to { + opacity: 1; + transform: scale(1); + } } .gn-detached-window.is-focused { border-color: ${isDark ? 'rgba(255,214,102,0.55)' : 'rgba(22,119,255,0.45)'}; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 1e58b6d6..a143e94d 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -7692,7 +7692,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc setActiveResultKey(''); }; - const openResultInWindow = (key: string) => { + const openResultInWindow = ( + key: string, + preferred?: { x?: number; y?: number; width?: number; height?: number }, + ) => { const target = resultSets.find((result) => result.key === key); if (!target) return; const index = resultSets.findIndex((result) => result.key === key); @@ -7706,6 +7709,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc connectionId: currentConnectionId || tab.connectionId || '', dbName: currentDb || tab.dbName || '', title, + ...(preferred?.x !== undefined ? { x: preferred.x } : {}), + ...(preferred?.y !== undefined ? { y: preferred.y } : {}), + ...(preferred?.width !== undefined ? { width: preferred.width } : {}), + ...(preferred?.height !== undefined ? { height: preferred.height } : {}), result: { key: target.key, sql: target.sql, diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx index eefe3dba..d17e81d0 100644 --- a/frontend/src/components/QueryEditorResultsPanel.tsx +++ b/frontend/src/components/QueryEditorResultsPanel.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { Button, Dropdown, Tabs, Tooltip, message, type MenuProps } from 'antd'; import { BugOutlined, CloseOutlined, CopyOutlined, EyeInvisibleOutlined, RobotOutlined } from '@ant-design/icons'; @@ -7,9 +7,20 @@ import type { QueryResultPaginationState } from '../utils/queryResultPagination' import { filterColumnNamesByGlobalHiddenColumns, useGlobalHiddenColumns } from '../utils/globalHiddenColumns'; import { t as defaultTranslate } from '../i18n'; import { useOptionalI18n } from '../i18n/provider'; +import { + resolveResultDetachPreferredBounds, + shouldDetachTabByDrag, + type DetachedWindowBounds, +} from '../utils/detachedWindow'; +import DetachDragPreview, { + buildDetachDragPreviewState, + type DetachDragPreviewState, +} from './DetachDragPreview'; import DataGrid from './DataGrid'; import LogPanel from './LogPanel'; +export type OpenResultInWindowPreferred = Partial>; + export const QUERY_EDITOR_SQL_LOG_TAB_KEY = '__gonavi_sql_execution_log__'; export type QueryEditorResultSet = { @@ -50,7 +61,7 @@ interface QueryEditorResultsPanelProps { onCloseResultTabsToLeft: (key: string) => void; onCloseResultTabsToRight: (key: string) => void; onCloseAllResultTabs: () => void; - onOpenResultInWindow?: (key: string) => void; + onOpenResultInWindow?: (key: string, preferred?: OpenResultInWindowPreferred) => void; onReloadResult: (key: string, sql: string) => void; onResultPageChange: (key: string, page: number, pageSize: number) => void; onDiagnoseExecutionError: () => void; @@ -90,6 +101,132 @@ const QueryEditorResultsPanel: React.FC = ({ const i18n = useOptionalI18n(); const t = i18n?.t ?? defaultTranslate; const globalHiddenColumns = useGlobalHiddenColumns(); + const [draggingResultKey, setDraggingResultKey] = useState(null); + const [detachDragPreview, setDetachDragPreview] = useState(null); + const resultTabDragRef = useRef<{ + key: string; + title: string; + startX: number; + startY: number; + active: boolean; + } | null>(null); + + const resolveResultTabTitle = useCallback((key: string) => { + const index = resultSets.findIndex((item) => item.key === key); + const rs = index >= 0 ? resultSets[index] : null; + if (!rs) return t('query_editor.results_panel.menu.open_in_window'); + if (rs.resultType === 'message') { + return t('query_editor.results_panel.tab.message', { index: index + 1 }); + } + return t('query_editor.results_panel.tab.result', { index: index + 1 }); + }, [resultSets, t]); + + const handleResultTabPointerDown = useCallback((event: React.PointerEvent, key: string) => { + if (!onOpenResultInWindow || event.button !== 0) return; + const target = event.target instanceof HTMLElement ? event.target : null; + if (target?.closest('.query-result-tab-close, button, a, input, textarea')) { + return; + } + const title = resolveResultTabTitle(key); + resultTabDragRef.current = { + key, + title, + startX: event.clientX, + startY: event.clientY, + active: false, + }; + + const previousUserSelect = document.body.style.userSelect; + const previousWebkitUserSelect = (document.body.style as CSSStyleDeclaration & { webkitUserSelect?: string }).webkitUserSelect || ''; + let selectionSuppressed = false; + + const clearNativeSelection = () => { + const selection = window.getSelection?.(); + if (selection && selection.rangeCount > 0) { + selection.removeAllRanges(); + } + }; + + const suppressTextSelection = () => { + if (!selectionSuppressed) { + selectionSuppressed = true; + document.body.style.userSelect = 'none'; + (document.body.style as CSSStyleDeclaration & { webkitUserSelect?: string }).webkitUserSelect = 'none'; + document.documentElement.classList.add('gn-result-tab-detaching'); + window.addEventListener('selectstart', preventSelectStart, true); + window.addEventListener('dragstart', preventSelectStart, true); + } + clearNativeSelection(); + }; + + const preventSelectStart = (selectEvent: Event) => { + selectEvent.preventDefault(); + selectEvent.stopPropagation(); + }; + + const clearListeners = () => { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', handleUp); + window.removeEventListener('pointercancel', handleUp); + window.removeEventListener('selectstart', preventSelectStart, true); + window.removeEventListener('dragstart', preventSelectStart, true); + if (selectionSuppressed) { + document.body.style.userSelect = previousUserSelect; + (document.body.style as CSSStyleDeclaration & { webkitUserSelect?: string }).webkitUserSelect = previousWebkitUserSelect; + document.documentElement.classList.remove('gn-result-tab-detaching'); + } + resultTabDragRef.current = null; + setDraggingResultKey(null); + setDetachDragPreview(null); + }; + + const handleMove = (moveEvent: PointerEvent) => { + const drag = resultTabDragRef.current; + if (!drag || drag.key !== key) return; + const dx = moveEvent.clientX - drag.startX; + const dy = moveEvent.clientY - drag.startY; + if (!drag.active && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) { + drag.active = true; + setDraggingResultKey(key); + suppressTextSelection(); + } + if (drag.active) { + // 拖出过程中禁止浏览器默认选中 SQL 编辑器文本 + moveEvent.preventDefault(); + suppressTextSelection(); + setDetachDragPreview(buildDetachDragPreviewState({ + title, + clientX: moveEvent.clientX, + clientY: moveEvent.clientY, + deltaY: dy, + })); + } + }; + + const handleUp = (upEvent: PointerEvent) => { + const drag = resultTabDragRef.current; + if (!drag || drag.key !== key) { + clearListeners(); + return; + } + const dy = upEvent.clientY - drag.startY; + const shouldDetach = drag.active && shouldDetachTabByDrag(dy); + if (drag.active) { + upEvent.preventDefault(); + clearNativeSelection(); + } + // 先清预览再打开真实窗口,避免叠两层 + clearListeners(); + if (shouldDetach) { + onOpenResultInWindow(key, resolveResultDetachPreferredBounds(upEvent.clientX, upEvent.clientY)); + } + }; + + window.addEventListener('pointermove', handleMove, { passive: false }); + window.addEventListener('pointerup', handleUp); + window.addEventListener('pointercancel', handleUp); + }, [onOpenResultInWindow, resolveResultTabTitle]); + const shouldShowSqlLogTab = isV2Ui && (sqlLogCount > 0 || activeResultKey === QUERY_EDITOR_SQL_LOG_TAB_KEY); const logTabCountLabel = sqlLogCount > 999 ? '999+' : String(sqlLogCount); const hideTooltipTitle = toggleShortcutLabel @@ -217,7 +354,12 @@ const QueryEditorResultsPanel: React.FC = ({ key: rs.key, label: ( -
event.preventDefault()}> +
event.preventDefault()} + onPointerDown={(event) => handleResultTabPointerDown(event, rs.key)} + > {rs.resultType === 'message' @@ -386,6 +528,15 @@ const QueryEditorResultsPanel: React.FC = ({ .query-result-tabs .ant-tabs-tabpane-hidden { display: none !important; } .query-result-tabs .ant-tabs-ink-bar { transition: none !important; } .query-result-tab-label { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 126px; height: 100%; line-height: 1; user-select: none; -webkit-user-select: none; } + .query-result-tab-label.is-detachable { cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; } + .query-result-tab-label.is-dragging-detach { cursor: grabbing; opacity: 0.72; } + html.gn-result-tab-detaching, + html.gn-result-tab-detaching body, + html.gn-result-tab-detaching * { + user-select: none !important; + -webkit-user-select: none !important; + cursor: grabbing !important; + } .query-result-tab-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 700; } .query-result-tab-count { flex: 0 0 auto; min-width: 17px; height: 17px; padding: 0 5px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; background: rgba(148, 163, 184, 0.16); color: inherit; font-size: 11px; font-weight: 700; line-height: 17px; } .query-result-tab-close { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 999px; color: #999; cursor: pointer; flex: 0 0 auto; } @@ -436,6 +587,11 @@ const QueryEditorResultsPanel: React.FC = ({ )}
+ ); }; diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 87704d60..788fe227 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -4,7 +4,7 @@ import { Button, Dropdown, message, Tabs, Tooltip } from 'antd'; import { AppstoreOutlined, CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, PlusOutlined, RobotOutlined, SettingOutlined } from '@ant-design/icons'; import type { MenuProps, TabsProps } from 'antd'; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core'; -import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core'; +import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core'; import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useStore } from '../store'; @@ -27,7 +27,14 @@ import { } from '../utils/sqlFileTabDirty'; import { clearSQLFileTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts'; import WorkbenchTabContent from './WorkbenchTabContent'; -import { shouldDetachTabByDrag } from '../utils/detachedWindow'; +import DetachDragPreview, { + buildDetachDragPreviewState, + type DetachDragPreviewState, +} from './DetachDragPreview'; +import { + resolveResultDetachPreferredBounds, + shouldDetachTabByDrag, +} from '../utils/detachedWindow'; const getTabKindLabel = (tab: TabData): string => { if (tab.type === 'query') return t('tab_manager.kind_badge.query'); @@ -408,6 +415,13 @@ const TabManager: React.FC = React.memo(() => { ); const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)'; const [draggingTabId, setDraggingTabId] = useState(null); + const [detachDragPreview, setDetachDragPreview] = useState(null); + const detachDragSessionRef = useRef<{ + tabId: string; + title: string; + startX: number; + startY: number; + } | null>(null); const suppressClickUntilRef = useRef(0); const sensors = useSensors( useSensor(PointerSensor, { @@ -575,29 +589,60 @@ const TabManager: React.FC = React.memo(() => { } }; + const clearDetachDragSession = useCallback(() => { + detachDragSessionRef.current = null; + setDetachDragPreview(null); + document.documentElement.classList.remove('gn-workbench-tab-detaching'); + }, []); + const handleDragStart = (event: DragStartEvent) => { const sourceId = String(event.active.id || '').trim(); setDraggingTabId(sourceId || null); + const tab = dockedTabs.find((item) => item.id === sourceId); + const connection = connections.find((conn) => conn.id === tab?.connectionId); + const displayModel = tab + ? buildTabDisplayModel(tab, connection, appearance.tabDisplay, t) + : null; + const title = displayModel?.fullTitle || tab?.title || t('tab_manager.detached.title_fallback'); + const pointerEvent = event.activatorEvent as PointerEvent | MouseEvent | undefined; + const startX = typeof pointerEvent?.clientX === 'number' ? pointerEvent.clientX : 0; + const startY = typeof pointerEvent?.clientY === 'number' ? pointerEvent.clientY : 0; + detachDragSessionRef.current = sourceId + ? { tabId: sourceId, title, startX, startY } + : null; + document.documentElement.classList.add('gn-workbench-tab-detaching'); + }; + + const handleDragMove = (event: DragMoveEvent) => { + const session = detachDragSessionRef.current; + if (!session) return; + const deltaX = Number(event.delta?.x || 0); + const deltaY = Number(event.delta?.y || 0); + setDetachDragPreview(buildDetachDragPreviewState({ + title: session.title, + clientX: session.startX + deltaX, + clientY: session.startY + deltaY, + deltaY, + })); }; const handleDragEnd = (event: DragEndEvent) => { const sourceId = String(event.active.id || '').trim(); const targetId = String(event.over?.id || '').trim(); + const deltaX = Number(event.delta?.x || 0); const deltaY = Number(event.delta?.y || 0); + const session = detachDragSessionRef.current; setDraggingTabId(null); + clearDetachDragSession(); if (!sourceId) { return; } if (shouldDetachTabByDrag(deltaY, targetId || null)) { suppressClickUntilRef.current = Date.now() + 120; - const pointerEvent = event.activatorEvent as PointerEvent | MouseEvent | undefined; - const preferredX = typeof pointerEvent?.clientX === 'number' - ? Math.max(16, pointerEvent.clientX - 120) - : undefined; - const preferredY = typeof pointerEvent?.clientY === 'number' - ? Math.max(16, pointerEvent.clientY - 20) - : undefined; - detachWorkbenchTab(sourceId, { x: preferredX, y: preferredY }); + const releaseX = (session?.startX ?? 0) + deltaX; + const releaseY = (session?.startY ?? 0) + deltaY; + const preferred = resolveResultDetachPreferredBounds(releaseX, releaseY); + detachWorkbenchTab(sourceId, preferred); return; } if (!targetId || sourceId === targetId) { @@ -609,6 +654,7 @@ const TabManager: React.FC = React.memo(() => { const handleDragCancel = () => { setDraggingTabId(null); + clearDetachDragSession(); }; React.useEffect(() => { @@ -979,6 +1025,12 @@ body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active { color: var(--gn-fg-2); font-weight: 600; } + html.gn-workbench-tab-detaching, + html.gn-workbench-tab-detaching body, + html.gn-workbench-tab-detaching * { + user-select: none !important; + -webkit-user-select: none !important; + } `} {isV2Ui && !hasTabs ? ( EmptyWorkbench @@ -990,6 +1042,7 @@ body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active { sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} + onDragMove={handleDragMove} onDragEnd={handleDragEnd} onDragCancel={handleDragCancel} > @@ -1011,6 +1064,11 @@ body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active { )} +
); }); diff --git a/frontend/src/utils/detachedWindow.test.ts b/frontend/src/utils/detachedWindow.test.ts index fb654bcf..2a0d027a 100644 --- a/frontend/src/utils/detachedWindow.test.ts +++ b/frontend/src/utils/detachedWindow.test.ts @@ -4,6 +4,7 @@ import { DETACH_TAB_DRAG_Y_THRESHOLD, nextDetachedZIndex, resolveDetachedWindowTitle, + resolveResultDetachPreferredBounds, shouldDetachTabByDrag, } from './detachedWindow'; @@ -37,4 +38,9 @@ describe('detachedWindow helpers', () => { fallbackTitle: 'Query 1', })).toBe('Query 1'); }); + + it('maps pointer release position to floating window preferred bounds', () => { + expect(resolveResultDetachPreferredBounds(200, 300)).toEqual({ x: 80, y: 276 }); + expect(resolveResultDetachPreferredBounds(10, 10)).toEqual({ x: 16, y: 16 }); + }); }); diff --git a/frontend/src/utils/detachedWindow.ts b/frontend/src/utils/detachedWindow.ts index dd00bb4f..a8dec2ba 100644 --- a/frontend/src/utils/detachedWindow.ts +++ b/frontend/src/utils/detachedWindow.ts @@ -116,6 +116,15 @@ export const shouldDetachTabByDrag = (deltaY: number, overId?: string | null): b return Math.abs(deltaY) >= DETACH_TAB_DRAG_Y_THRESHOLD; }; +/** 从指针位置推算浮动窗落点,便于结果 Tab 拖出时「跟手」打开 */ +export const resolveResultDetachPreferredBounds = ( + clientX: number, + clientY: number, +): Partial> => ({ + x: Math.max(DETACHED_WINDOW_VIEWPORT_PADDING, Math.round(clientX - 120)), + y: Math.max(DETACHED_WINDOW_VIEWPORT_PADDING, Math.round(clientY - 24)), +}); + export const resolveDetachedWindowTitle = (params: { kindLabel: string; objectLabel?: string;