feat(tab/window): 完善Tab拖出独立窗口跟手预览与入场动效

- 结果Tab支持拖出拆分,拖拽过程抑制文本选中
- 抽取 DetachDragPreview:进度条/跟手预览/阈值高亮
- 工作区主Tab拖出复用同一套预览动效,松手位置跟手
- 浮动窗增加 scale 入场动画,增强拆出反馈
This commit is contained in:
Syngnat
2026-07-09 09:44:53 +08:00
parent c1cf19ef67
commit 6fb6674803
8 changed files with 476 additions and 14 deletions

View File

@@ -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<DetachDragPreviewProps> = ({
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(
<>
<style>{`
.gn-detach-preview {
position: fixed;
z-index: 1400;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 12px;
border: 1px solid rgba(22, 119, 255, 0.28);
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.22);
pointer-events: none;
transition: width 80ms ease-out, height 80ms ease-out, transform 80ms ease-out, opacity 80ms ease-out, border-color 120ms ease, box-shadow 120ms ease;
}
.gn-detach-preview.is-dark {
border-color: rgba(255, 214, 102, 0.4);
background: rgba(28, 30, 36, 0.96);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
color: rgba(255, 255, 255, 0.92);
}
.gn-detach-preview.is-ready {
border-color: rgba(22, 119, 255, 0.55);
box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.2), 0 20px 48px rgba(15, 23, 42, 0.28);
}
.gn-detach-preview.is-dark.is-ready {
border-color: rgba(255, 214, 102, 0.7);
box-shadow: 0 0 0 1px rgba(255, 214, 102, 0.25), 0 20px 48px rgba(0, 0, 0, 0.55);
}
.gn-detach-preview-chrome {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 6px 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(0, 0, 0, 0.03);
font-size: 12px;
}
.gn-detach-preview.is-dark .gn-detach-preview-chrome {
border-bottom-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.gn-detach-preview-chrome strong {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
}
.gn-detach-preview-chrome em {
flex: 0 0 auto;
font-style: normal;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: rgba(22, 119, 255, 0.9);
}
.gn-detach-preview.is-dark .gn-detach-preview-chrome em {
color: rgba(255, 214, 102, 0.95);
}
.gn-detach-preview-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
position: relative;
}
.gn-detach-preview-body > span {
font-size: 12px;
font-weight: 600;
color: rgba(0, 0, 0, 0.55);
}
.gn-detach-preview.is-dark .gn-detach-preview-body > span {
color: rgba(255, 255, 255, 0.55);
}
.gn-detach-preview-progress {
height: 4px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.gn-detach-preview.is-dark .gn-detach-preview-progress {
background: rgba(255, 255, 255, 0.08);
}
.gn-detach-preview-progress > i {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, rgba(22, 119, 255, 0.75), rgba(64, 150, 255, 0.95));
transition: width 80ms linear;
}
.gn-detach-preview.is-dark .gn-detach-preview-progress > i {
background: linear-gradient(90deg, rgba(255, 214, 102, 0.7), rgba(255, 230, 150, 0.95));
}
.gn-detach-preview-grid {
flex: 1 1 auto;
min-height: 24px;
border-radius: 8px;
border: 1px dashed rgba(0, 0, 0, 0.1);
background:
linear-gradient(rgba(22, 119, 255, 0.04) 1px, transparent 1px) 0 0 / 100% 18px,
linear-gradient(90deg, rgba(22, 119, 255, 0.04) 1px, transparent 1px) 0 0 / 48px 100%;
}
.gn-detach-preview.is-dark .gn-detach-preview-grid {
border-color: rgba(255, 255, 255, 0.1);
background:
linear-gradient(rgba(255, 214, 102, 0.06) 1px, transparent 1px) 0 0 / 100% 18px,
linear-gradient(90deg, rgba(255, 214, 102, 0.06) 1px, transparent 1px) 0 0 / 48px 100%;
}
`}</style>
<div
className={`gn-detach-preview${preview.willDetach ? ' is-ready' : ''}${darkMode ? ' is-dark' : ''}`}
style={{
left,
top,
width: previewWidth,
height: previewHeight,
opacity: 0.9 + preview.progress * 0.1,
transform: `scale(${0.9 + preview.progress * 0.12})`,
}}
aria-hidden
>
<div className="gn-detach-preview-chrome">
<ExpandOutlined />
<strong>{preview.title}</strong>
<em>{Math.round(preview.progress * 100)}%</em>
</div>
<div className="gn-detach-preview-body">
<span>{hint}</span>
<div className="gn-detach-preview-progress">
<i style={{ width: `${Math.max(8, preview.progress * 100)}%` }} />
</div>
<div className="gn-detach-preview-grid" />
</div>
</div>
</>,
document.body,
);
}, [darkMode, preview, readyHint]);
return portal;
};
export default DetachDragPreview;

View File

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

View File

@@ -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)'};

View File

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

View File

@@ -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<Pick<DetachedWindowBounds, 'x' | 'y' | 'width' | 'height'>>;
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<QueryEditorResultsPanelProps> = ({
const i18n = useOptionalI18n();
const t = i18n?.t ?? defaultTranslate;
const globalHiddenColumns = useGlobalHiddenColumns();
const [draggingResultKey, setDraggingResultKey] = useState<string | null>(null);
const [detachDragPreview, setDetachDragPreview] = useState<DetachDragPreviewState | null>(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<HTMLElement>, 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<QueryEditorResultsPanelProps> = ({
key: rs.key,
label: (
<Dropdown menu={{ items: buildResultTabMenuItems(rs.key, idx) }} trigger={['contextMenu']} rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}>
<div className="query-result-tab-label" onContextMenu={(event) => event.preventDefault()}>
<div
className={`query-result-tab-label${onOpenResultInWindow ? ' is-detachable' : ''}${draggingResultKey === rs.key ? ' is-dragging-detach' : ''}`}
title={onOpenResultInWindow ? t('query_editor.results_panel.menu.open_in_window') : undefined}
onContextMenu={(event) => event.preventDefault()}
onPointerDown={(event) => handleResultTabPointerDown(event, rs.key)}
>
<Tooltip title={rs.sql}>
<span className="query-result-tab-text">
{rs.resultType === 'message'
@@ -386,6 +528,15 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
.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<QueryEditorResultsPanelProps> = ({
</>
)}
</div>
<DetachDragPreview
preview={detachDragPreview}
darkMode={darkMode}
readyHint={t('query_editor.results_panel.menu.open_in_window')}
/>
</>
);
};

View File

@@ -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<string | null>(null);
const [detachDragPreview, setDetachDragPreview] = useState<DetachDragPreviewState | null>(null);
const detachDragSessionRef = useRef<{
tabId: string;
title: string;
startX: number;
startY: number;
} | null>(null);
const suppressClickUntilRef = useRef<number>(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;
}
`}</style>
{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 {
</SortableContext>
</DndContext>
)}
<DetachDragPreview
preview={detachDragPreview}
darkMode={theme === 'dark'}
readyHint={t('tab_manager.menu.open_in_window')}
/>
</div>
);
});

View File

@@ -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 });
});
});

View File

@@ -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<Pick<DetachedWindowBounds, 'x' | 'y'>> => ({
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;