mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-17 02:42:06 +08:00
- 结果Tab支持拖出拆分,拖拽过程抑制文本选中 - 抽取 DetachDragPreview:进度条/跟手预览/阈值高亮 - 工作区主Tab拖出复用同一套预览动效,松手位置跟手 - 浮动窗增加 scale 入场动画,增强拆出反馈
370 lines
13 KiB
TypeScript
370 lines
13 KiB
TypeScript
import React, { useCallback, useMemo, useRef } from 'react';
|
|
import { Button, Tooltip } from 'antd';
|
|
import { CloseOutlined, CompressOutlined } from '@ant-design/icons';
|
|
import { useStore } from '../store';
|
|
import { t } from '../i18n';
|
|
import {
|
|
buildTabDisplayModel,
|
|
resolveConnectionHostSummary,
|
|
} from '../utils/tabDisplay';
|
|
import {
|
|
clamp,
|
|
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
|
|
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
|
|
DETACHED_WINDOW_VIEWPORT_PADDING,
|
|
resolveDetachedWindowTitle,
|
|
} from '../utils/detachedWindow';
|
|
import WorkbenchTabContent from './WorkbenchTabContent';
|
|
|
|
const getTabKindLabel = (type: string): string => {
|
|
if (type === 'query') return t('tab_manager.kind_badge.query');
|
|
if (type === 'table') return t('tab_manager.kind_badge.table');
|
|
if (type === 'design') return t('tab_manager.kind_badge.design');
|
|
if (type === 'table-overview') return t('tab_manager.kind_badge.table_overview');
|
|
if (type === 'table-export') return t('tab_manager.kind_badge.table_export');
|
|
if (type === 'sql-file-execution') return t('sidebar.sql_file_exec.title');
|
|
if (type === 'sql-analysis') return t('tab_manager.kind_badge.sql_analysis');
|
|
if (type.startsWith('redis')) return t('tab_manager.kind_badge.redis');
|
|
if (type.startsWith('jvm')) return t('tab_manager.kind_badge.jvm');
|
|
if (type === 'trigger') return t('tab_manager.kind_badge.trigger');
|
|
if (type === 'view-def') return t('tab_manager.kind_badge.view');
|
|
if (type === 'event-def') return t('tab_manager.kind_badge.event');
|
|
if (type === 'routine-def') return t('tab_manager.kind_badge.routine');
|
|
if (type === 'sequence-def') return t('tab_manager.kind_badge.sequence');
|
|
if (type === 'package-def') return t('tab_manager.kind_badge.package');
|
|
return t('tab_manager.kind_badge.fallback');
|
|
};
|
|
|
|
type DragMode = 'move' | 'resize-e' | 'resize-s' | 'resize-se';
|
|
|
|
const FloatingWorkbenchWindows: React.FC = () => {
|
|
const tabs = useStore((state) => state.tabs);
|
|
const connections = useStore((state) => state.connections);
|
|
const appearance = useStore((state) => state.appearance);
|
|
const theme = useStore((state) => state.theme);
|
|
const detachedWorkbenchWindows = useStore((state) => state.detachedWorkbenchWindows);
|
|
const activeTabId = useStore((state) => state.activeTabId);
|
|
const attachWorkbenchTab = useStore((state) => state.attachWorkbenchTab);
|
|
const closeTab = useStore((state) => state.closeTab);
|
|
const updateDetachedWorkbenchBounds = useStore((state) => state.updateDetachedWorkbenchBounds);
|
|
const focusDetachedWorkbenchTab = useStore((state) => state.focusDetachedWorkbenchTab);
|
|
const dragRef = useRef<{
|
|
tabId: string;
|
|
mode: DragMode;
|
|
startX: number;
|
|
startY: number;
|
|
originX: number;
|
|
originY: number;
|
|
originW: number;
|
|
originH: number;
|
|
} | null>(null);
|
|
|
|
const windowModels = useMemo(() => {
|
|
return detachedWorkbenchWindows
|
|
.map((windowState) => {
|
|
const tab = tabs.find((item) => item.id === windowState.tabId);
|
|
if (!tab) return null;
|
|
const connection = connections.find((conn) => conn.id === tab.connectionId);
|
|
const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay, t);
|
|
const kindLabel = getTabKindLabel(tab.type);
|
|
const objectLabel =
|
|
tab.tableName ||
|
|
tab.viewName ||
|
|
tab.eventName ||
|
|
tab.routineName ||
|
|
tab.sequenceName ||
|
|
tab.packageName ||
|
|
tab.triggerName ||
|
|
tab.resourcePath ||
|
|
tab.filePath ||
|
|
'';
|
|
const title = resolveDetachedWindowTitle({
|
|
kindLabel,
|
|
objectLabel,
|
|
fallbackTitle: displayModel.fullTitle || tab.title || t('tab_manager.detached.title_fallback'),
|
|
});
|
|
return {
|
|
windowState,
|
|
tab,
|
|
title,
|
|
hostSummary: resolveConnectionHostSummary(connection?.config),
|
|
connectionName: connection?.name || '',
|
|
isFocused: activeTabId === tab.id,
|
|
};
|
|
})
|
|
.filter(Boolean) as Array<{
|
|
windowState: (typeof detachedWorkbenchWindows)[number];
|
|
tab: (typeof tabs)[number];
|
|
title: string;
|
|
hostSummary: string;
|
|
connectionName: string;
|
|
isFocused: boolean;
|
|
}>;
|
|
}, [activeTabId, appearance.tabDisplay, connections, detachedWorkbenchWindows, tabs]);
|
|
|
|
const startInteraction = useCallback((
|
|
event: React.PointerEvent,
|
|
tabId: string,
|
|
mode: DragMode,
|
|
bounds: { x: number; y: number; width: number; height: number },
|
|
) => {
|
|
if (event.button !== 0) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
focusDetachedWorkbenchTab(tabId);
|
|
dragRef.current = {
|
|
tabId,
|
|
mode,
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
originX: bounds.x,
|
|
originY: bounds.y,
|
|
originW: bounds.width,
|
|
originH: bounds.height,
|
|
};
|
|
|
|
const handleMove = (moveEvent: PointerEvent) => {
|
|
const drag = dragRef.current;
|
|
if (!drag) return;
|
|
const dx = moveEvent.clientX - drag.startX;
|
|
const dy = moveEvent.clientY - drag.startY;
|
|
if (drag.mode === 'move') {
|
|
const maxX = Math.max(
|
|
DETACHED_WINDOW_VIEWPORT_PADDING,
|
|
window.innerWidth - drag.originW - DETACHED_WINDOW_VIEWPORT_PADDING,
|
|
);
|
|
const maxY = Math.max(
|
|
DETACHED_WINDOW_VIEWPORT_PADDING,
|
|
window.innerHeight - drag.originH - DETACHED_WINDOW_VIEWPORT_PADDING,
|
|
);
|
|
updateDetachedWorkbenchBounds(drag.tabId, {
|
|
x: clamp(drag.originX + dx, DETACHED_WINDOW_VIEWPORT_PADDING, maxX),
|
|
y: clamp(drag.originY + dy, DETACHED_WINDOW_VIEWPORT_PADDING, maxY),
|
|
});
|
|
return;
|
|
}
|
|
let nextW = drag.originW;
|
|
let nextH = drag.originH;
|
|
if (drag.mode === 'resize-e' || drag.mode === 'resize-se') {
|
|
nextW = clamp(
|
|
drag.originW + dx,
|
|
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
|
|
window.innerWidth - drag.originX - DETACHED_WINDOW_VIEWPORT_PADDING,
|
|
);
|
|
}
|
|
if (drag.mode === 'resize-s' || drag.mode === 'resize-se') {
|
|
nextH = clamp(
|
|
drag.originH + dy,
|
|
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
|
|
window.innerHeight - drag.originY - DETACHED_WINDOW_VIEWPORT_PADDING,
|
|
);
|
|
}
|
|
updateDetachedWorkbenchBounds(drag.tabId, { width: nextW, height: nextH });
|
|
};
|
|
|
|
const stop = () => {
|
|
dragRef.current = null;
|
|
window.removeEventListener('pointermove', handleMove);
|
|
window.removeEventListener('pointerup', stop);
|
|
window.removeEventListener('pointercancel', stop);
|
|
};
|
|
|
|
window.addEventListener('pointermove', handleMove);
|
|
window.addEventListener('pointerup', stop);
|
|
window.addEventListener('pointercancel', stop);
|
|
}, [focusDetachedWorkbenchTab, updateDetachedWorkbenchBounds]);
|
|
|
|
if (windowModels.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const isDark = theme === 'dark';
|
|
|
|
return (
|
|
<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;
|
|
}
|
|
.gn-detached-window {
|
|
position: fixed;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: ${DEFAULT_DETACHED_WINDOW_MIN_WIDTH}px;
|
|
min-height: ${DEFAULT_DETACHED_WINDOW_MIN_HEIGHT}px;
|
|
border-radius: 10px;
|
|
border: 1px solid ${isDark ? 'rgba(255,255,255,0.14)' : 'rgba(0,0,0,0.12)'};
|
|
background: ${isDark ? 'rgba(22,24,28,0.98)' : 'rgba(255,255,255,0.98)'};
|
|
box-shadow: ${isDark
|
|
? '0 18px 48px rgba(0,0,0,0.45)'
|
|
: '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)'};
|
|
box-shadow: ${isDark
|
|
? '0 0 0 1px rgba(255,214,102,0.25), 0 20px 52px rgba(0,0,0,0.5)'
|
|
: '0 0 0 1px rgba(22,119,255,0.18), 0 20px 52px rgba(15,23,42,0.2)'};
|
|
}
|
|
.gn-detached-window-header {
|
|
flex: 0 0 auto;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-height: 40px;
|
|
padding: 6px 8px 6px 12px;
|
|
border-bottom: 1px solid ${isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
|
|
background: ${isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)'};
|
|
cursor: move;
|
|
user-select: none;
|
|
}
|
|
.gn-detached-window-title {
|
|
min-width: 0;
|
|
flex: 1 1 auto;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: ${isDark ? 'rgba(255,255,255,0.92)' : 'rgba(0,0,0,0.88)'};
|
|
}
|
|
.gn-detached-window-subtitle {
|
|
display: block;
|
|
margin-top: 1px;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: ${isDark ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)'};
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.gn-detached-window-actions {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
flex: 0 0 auto;
|
|
}
|
|
.gn-detached-window-body {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
.gn-detached-window-body > * {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
min-width: 0;
|
|
}
|
|
.gn-detached-resize-handle {
|
|
position: absolute;
|
|
z-index: 2;
|
|
}
|
|
.gn-detached-resize-e {
|
|
top: 36px;
|
|
right: 0;
|
|
width: 8px;
|
|
height: calc(100% - 36px);
|
|
cursor: ew-resize;
|
|
}
|
|
.gn-detached-resize-s {
|
|
left: 0;
|
|
bottom: 0;
|
|
width: 100%;
|
|
height: 8px;
|
|
cursor: ns-resize;
|
|
}
|
|
.gn-detached-resize-se {
|
|
right: 0;
|
|
bottom: 0;
|
|
width: 16px;
|
|
height: 16px;
|
|
cursor: nwse-resize;
|
|
}
|
|
`}</style>
|
|
{windowModels.map(({ windowState, tab, title, hostSummary, connectionName, isFocused }) => (
|
|
<div
|
|
key={windowState.tabId}
|
|
className={`gn-detached-window${isFocused ? ' is-focused' : ''}`}
|
|
style={{
|
|
left: windowState.x,
|
|
top: windowState.y,
|
|
width: windowState.width,
|
|
height: windowState.height,
|
|
zIndex: windowState.zIndex,
|
|
}}
|
|
onMouseDown={() => focusDetachedWorkbenchTab(windowState.tabId)}
|
|
>
|
|
<div
|
|
className="gn-detached-window-header"
|
|
onPointerDown={(event) => startInteraction(event, windowState.tabId, 'move', windowState)}
|
|
>
|
|
<div className="gn-detached-window-title" title={title}>
|
|
<span>{title}</span>
|
|
{(connectionName || hostSummary) ? (
|
|
<span className="gn-detached-window-subtitle">
|
|
{[connectionName, hostSummary].filter(Boolean).join(' · ')}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="gn-detached-window-actions" onPointerDown={(event) => event.stopPropagation()}>
|
|
<Tooltip title={t('tab_manager.detached.restore')}>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={<CompressOutlined />}
|
|
aria-label={t('tab_manager.detached.restore')}
|
|
onClick={() => attachWorkbenchTab(windowState.tabId)}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title={t('tab_manager.detached.close')}>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={<CloseOutlined />}
|
|
aria-label={t('tab_manager.detached.close')}
|
|
onClick={() => closeTab(windowState.tabId)}
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
<div className="gn-detached-window-body">
|
|
<WorkbenchTabContent tab={tab} isActive={isFocused || activeTabId === tab.id} />
|
|
</div>
|
|
<div
|
|
className="gn-detached-resize-handle gn-detached-resize-e"
|
|
onPointerDown={(event) => startInteraction(event, windowState.tabId, 'resize-e', windowState)}
|
|
/>
|
|
<div
|
|
className="gn-detached-resize-handle gn-detached-resize-s"
|
|
onPointerDown={(event) => startInteraction(event, windowState.tabId, 'resize-s', windowState)}
|
|
/>
|
|
<div
|
|
className="gn-detached-resize-handle gn-detached-resize-se"
|
|
onPointerDown={(event) => startInteraction(event, windowState.tabId, 'resize-se', windowState)}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FloatingWorkbenchWindows;
|