mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-15 09:23:08 +08:00
✨ feat(tab/window): 完善Tab拖出独立窗口跟手预览与入场动效
- 结果Tab支持拖出拆分,拖拽过程抑制文本选中 - 抽取 DetachDragPreview:进度条/跟手预览/阈值高亮 - 工作区主Tab拖出复用同一套预览动效,松手位置跟手 - 浮动窗增加 scale 入场动画,增强拆出反馈
This commit is contained in:
202
frontend/src/components/DetachDragPreview.tsx
Normal file
202
frontend/src/components/DetachDragPreview.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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)'};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user