🐛 fix(ui): 修复拖拽与缩放残留导致的交互锁死

- 统一管理 pointer capture、全局监听器与选择状态生命周期
- 在失焦、捕获丢失、按键释放和组件卸载时自动清理交互状态
- 覆盖 AI、查询结果、工作台与结果比对浮窗拖拽缩放
- 修复 Redis、表设计、通用弹窗、数据网格和侧栏的残留监听
- 增加异常释放、重复终止和卸载清理等交互回归测试
This commit is contained in:
Syngnat
2026-07-22 18:56:54 +08:00
parent c19fcdd183
commit e30c2d8c62
28 changed files with 3102 additions and 536 deletions

View File

@@ -0,0 +1,276 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import FloatingAIChatWindow from './FloatingAIChatWindow';
type Listener = (event: any) => void;
class FakeEventTarget {
private listeners = new Map<string, Set<Listener>>();
addEventListener(type: string, listener: Listener) {
const listeners = this.listeners.get(type) ?? new Set<Listener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener) {
this.listeners.get(type)?.delete(listener);
}
dispatch(type: string, event: Record<string, unknown> = {}) {
for (const listener of [...(this.listeners.get(type) ?? [])]) {
listener({ type, ...event });
}
}
listenerCount(type: string) {
return this.listeners.get(type)?.size ?? 0;
}
}
class FakePointerCaptureTarget extends FakeEventTarget {
private capturedPointers = new Set<number>();
setPointerCapture = vi.fn((pointerId: number) => {
this.capturedPointers.add(pointerId);
});
hasPointerCapture = vi.fn((pointerId: number) => this.capturedPointers.has(pointerId));
releasePointerCapture = vi.fn((pointerId: number) => {
this.capturedPointers.delete(pointerId);
});
}
const storeState = vi.hoisted(() => ({
theme: 'light',
detachedAIChatWindow: {
x: 120,
y: 90,
width: 520,
height: 560,
zIndex: 100,
} as null | { x: number; y: number; width: number; height: number; zIndex: number },
attachAIChatPanel: vi.fn(),
setAIPanelVisible: vi.fn(),
updateDetachedAIChatBounds: vi.fn(),
focusDetachedAIChatPanel: vi.fn(),
}));
vi.mock('../store', () => ({
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
vi.mock('../utils/nativeDetachedWindowHost', () => ({
hasNativeDetachedWindowManager: () => false,
}));
vi.mock('antd', () => ({
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" {...props}>{children}</button>
),
ConfigProvider: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
Spin: () => <span data-spin="true" />,
}));
vi.mock('./AIChatPanel', () => ({
default: ({ onWindowDragStart }: { onWindowDragStart?: (event: React.PointerEvent) => void }) => (
<div className="ai-chat-panel">
<div className="ai-chat-header" onPointerDown={onWindowDragStart} />
</div>
),
}));
describe('FloatingAIChatWindow pointer interaction lifecycle', () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
let fakeWindow: FakeEventTarget & { innerHeight: number; innerWidth: number };
let renderer: ReactTestRenderer | null = null;
beforeEach(() => {
vi.clearAllMocks();
storeState.detachedAIChatWindow = {
x: 120,
y: 90,
width: 520,
height: 560,
zIndex: 100,
};
fakeWindow = Object.assign(new FakeEventTarget(), {
innerHeight: 900,
innerWidth: 1440,
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: fakeWindow,
});
});
afterEach(() => {
act(() => {
renderer?.unmount();
});
renderer = null;
if (previousWindowDescriptor) {
Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
});
const renderWindow = async () => {
await act(async () => {
renderer = create(
<FloatingAIChatWindow
darkMode={false}
overlayTheme={{} as OverlayWorkbenchTheme}
onOpenSettings={vi.fn()}
/>,
);
await Promise.resolve();
});
};
const beginEastResize = () => {
const handle = renderer?.root.findByProps({ className: 'gn-detached-ai-chat-resize-e' });
const captureTarget = new FakePointerCaptureTarget();
act(() => {
handle?.props.onPointerDown({
button: 0,
buttons: 1,
clientX: 640,
clientY: 300,
currentTarget: captureTarget,
pointerId: 7,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as React.PointerEvent);
});
return captureTarget;
};
it('removes an active instance global pointer listeners before unmounting', async () => {
await renderWindow();
beginEastResize();
expect(fakeWindow.listenerCount('pointermove')).toBe(1);
expect(fakeWindow.listenerCount('pointerup')).toBe(1);
expect(fakeWindow.listenerCount('pointercancel')).toBe(1);
act(() => {
renderer?.unmount();
});
renderer = null;
expect(fakeWindow.listenerCount('pointermove')).toBe(0);
expect(fakeWindow.listenerCount('pointerup')).toBe(0);
expect(fakeWindow.listenerCount('pointercancel')).toBe(0);
fakeWindow.dispatch('pointermove', {
buttons: 1,
clientX: 720,
clientY: 300,
pointerId: 7,
});
expect(storeState.updateDetachedAIChatBounds).not.toHaveBeenCalled();
});
it('captures and updates only the pointer that started the interaction', async () => {
await renderWindow();
const captureTarget = beginEastResize();
expect(captureTarget.setPointerCapture).toHaveBeenCalledWith(7);
fakeWindow.dispatch('pointermove', {
buttons: 1,
clientX: 900,
clientY: 300,
pointerId: 9,
});
fakeWindow.dispatch('pointerup', { pointerId: 9 });
expect(storeState.updateDetachedAIChatBounds).not.toHaveBeenCalled();
expect(fakeWindow.listenerCount('pointermove')).toBe(1);
fakeWindow.dispatch('pointermove', {
buttons: 1,
clientX: 720,
clientY: 300,
pointerId: 7,
});
expect(storeState.updateDetachedAIChatBounds).toHaveBeenLastCalledWith({
height: 560,
width: 600,
});
fakeWindow.dispatch('pointerup', { pointerId: 7 });
expect(captureTarget.releasePointerCapture).toHaveBeenCalledWith(7);
expect(fakeWindow.listenerCount('pointermove')).toBe(0);
expect(fakeWindow.listenerCount('pointerup')).toBe(0);
expect(fakeWindow.listenerCount('pointercancel')).toBe(0);
});
it('ends the interaction when the browser loses pointer capture', async () => {
await renderWindow();
const captureTarget = beginEastResize();
expect(captureTarget.listenerCount('lostpointercapture')).toBe(1);
captureTarget.dispatch('lostpointercapture', { pointerId: 7 });
expect(captureTarget.listenerCount('lostpointercapture')).toBe(0);
expect(fakeWindow.listenerCount('pointermove')).toBe(0);
expect(fakeWindow.listenerCount('pointerup')).toBe(0);
expect(fakeWindow.listenerCount('pointercancel')).toBe(0);
});
it('ends the interaction when the window loses focus', async () => {
await renderWindow();
const captureTarget = beginEastResize();
expect(fakeWindow.listenerCount('blur')).toBe(1);
fakeWindow.dispatch('blur');
expect(captureTarget.releasePointerCapture).toHaveBeenCalledWith(7);
expect(fakeWindow.listenerCount('blur')).toBe(0);
expect(fakeWindow.listenerCount('pointermove')).toBe(0);
expect(fakeWindow.listenerCount('pointerup')).toBe(0);
expect(fakeWindow.listenerCount('pointercancel')).toBe(0);
});
it('self-heals when a move reports that the primary button is already released', async () => {
await renderWindow();
const captureTarget = beginEastResize();
fakeWindow.dispatch('pointermove', {
buttons: 0,
clientX: 720,
clientY: 300,
pointerId: 7,
});
expect(storeState.updateDetachedAIChatBounds).not.toHaveBeenCalled();
expect(captureTarget.releasePointerCapture).toHaveBeenCalledWith(7);
expect(fakeWindow.listenerCount('pointermove')).toBe(0);
expect(fakeWindow.listenerCount('pointerup')).toBe(0);
expect(fakeWindow.listenerCount('pointercancel')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
});
it('ends the interaction on pointer cancellation', async () => {
await renderWindow();
const captureTarget = beginEastResize();
fakeWindow.dispatch('pointercancel', { pointerId: 7 });
expect(captureTarget.releasePointerCapture).toHaveBeenCalledWith(7);
expect(fakeWindow.listenerCount('pointermove')).toBe(0);
expect(fakeWindow.listenerCount('pointerup')).toBe(0);
expect(fakeWindow.listenerCount('pointercancel')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
});
});

View File

@@ -10,6 +10,7 @@ import {
} from '../utils/detachedWindow';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { hasNativeDetachedWindowManager } from '../utils/nativeDetachedWindowHost';
import { useManagedPointerInteraction } from '../hooks/useManagedPointerInteraction';
import AIPanelErrorBoundary from './ai/AIPanelErrorBoundary';
const createLazyAIChatPanel = () => React.lazy(() => import('./AIChatPanel'));
@@ -42,6 +43,10 @@ const FloatingAIChatWindow: React.FC<FloatingAIChatWindowProps> = ({
const updateDetachedAIChatBounds = useStore((state) => state.updateDetachedAIChatBounds);
const focusDetachedAIChatPanel = useStore((state) => state.focusDetachedAIChatPanel);
const LazyAIChatPanel = useMemo(createLazyAIChatPanel, [renderNonce]);
const nativeWindowManagerAvailable = hasNativeDetachedWindowManager();
const { startInteraction: startManagedInteraction } = useManagedPointerInteraction(
Boolean(windowState) && !nativeWindowManagerAvailable,
);
const dragRef = useRef<{
mode: DragMode;
@@ -62,6 +67,50 @@ const FloatingAIChatWindow: React.FC<FloatingAIChatWindowProps> = ({
event.preventDefault();
event.stopPropagation();
focusDetachedAIChatPanel();
const started = startManagedInteraction(event, {
onMove: (moveEvent) => {
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,
);
updateDetachedAIChatBounds({
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_AI_CHAT_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_AI_CHAT_MIN_HEIGHT,
window.innerHeight - drag.originY - DETACHED_WINDOW_VIEWPORT_PADDING,
);
}
updateDetachedAIChatBounds({ width: nextW, height: nextH });
},
onStop: () => {
dragRef.current = null;
},
});
if (!started) return;
dragRef.current = {
mode,
startX: event.clientX,
@@ -71,59 +120,9 @@ const FloatingAIChatWindow: React.FC<FloatingAIChatWindowProps> = ({
originW: bounds.width,
originH: bounds.height,
};
}, [focusDetachedAIChatPanel, startManagedInteraction, updateDetachedAIChatBounds]);
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,
);
updateDetachedAIChatBounds({
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_AI_CHAT_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_AI_CHAT_MIN_HEIGHT,
window.innerHeight - drag.originY - DETACHED_WINDOW_VIEWPORT_PADDING,
);
}
updateDetachedAIChatBounds({ 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);
}, [focusDetachedAIChatPanel, updateDetachedAIChatBounds]);
if (!windowState || hasNativeDetachedWindowManager()) {
if (!windowState || nativeWindowManagerAvailable) {
return null;
}

View File

@@ -12,6 +12,7 @@ import {
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
DETACHED_WINDOW_VIEWPORT_PADDING,
} from '../utils/detachedWindow';
import { useManagedPointerInteraction } from '../hooks/useManagedPointerInteraction';
const createLazyDetachedResultDataGrid = () => React.lazy(() => import('./DataGrid'));
@@ -57,6 +58,10 @@ const FloatingQueryResultWindows: React.FC = () => {
originW: number;
originH: number;
} | null>(null);
const nativeWindowManagerAvailable = hasNativeDetachedWindowManager();
const { startInteraction: startManagedInteraction } = useManagedPointerInteraction(
detachedQueryResultWindows.length > 0 && !nativeWindowManagerAvailable,
);
const startInteraction = useCallback((
event: React.PointerEvent,
@@ -68,6 +73,50 @@ const FloatingQueryResultWindows: React.FC = () => {
event.preventDefault();
event.stopPropagation();
focusDetachedQueryResultWindow(id);
const started = startManagedInteraction(event, {
onMove: (moveEvent) => {
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,
);
updateDetachedQueryResultBounds(drag.id, {
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,
);
}
updateDetachedQueryResultBounds(drag.id, { width: nextW, height: nextH });
},
onStop: () => {
dragRef.current = null;
},
});
if (!started) return;
dragRef.current = {
id,
mode,
@@ -78,57 +127,7 @@ const FloatingQueryResultWindows: React.FC = () => {
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,
);
updateDetachedQueryResultBounds(drag.id, {
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,
);
}
updateDetachedQueryResultBounds(drag.id, { 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);
}, [focusDetachedQueryResultWindow, updateDetachedQueryResultBounds]);
}, [focusDetachedQueryResultWindow, startManagedInteraction, updateDetachedQueryResultBounds]);
const handleRestore = useCallback((id: string) => {
const restored = attachQueryResultWindow(id);
@@ -143,7 +142,7 @@ const FloatingQueryResultWindows: React.FC = () => {
const windows = useMemo(() => detachedQueryResultWindows, [detachedQueryResultWindows]);
if (hasNativeDetachedWindowManager() || windows.length === 0) {
if (nativeWindowManagerAvailable || windows.length === 0) {
return null;
}

View File

@@ -17,6 +17,7 @@ import {
import WorkbenchTabContent from './WorkbenchTabContent';
import { hasNativeDetachedWindowManager } from '../utils/nativeDetachedWindowHost';
import { useWorkbenchTabs } from '../hooks/useWorkbenchTabs';
import { useManagedPointerInteraction } from '../hooks/useManagedPointerInteraction';
const getTabKindLabel = (type: string): string => {
if (type === 'query') return t('tab_manager.kind_badge.query');
@@ -106,6 +107,10 @@ const FloatingWorkbenchWindows: React.FC = () => {
isFocused: boolean;
}>;
}, [activeTabId, appearance.tabDisplay, connections, detachedWorkbenchWindows, tabs]);
const nativeWindowManagerAvailable = hasNativeDetachedWindowManager();
const { startInteraction: startManagedInteraction } = useManagedPointerInteraction(
windowModels.length > 0 && !nativeWindowManagerAvailable,
);
const startInteraction = useCallback((
event: React.PointerEvent,
@@ -117,6 +122,50 @@ const FloatingWorkbenchWindows: React.FC = () => {
event.preventDefault();
event.stopPropagation();
focusDetachedWorkbenchTab(tabId);
const started = startManagedInteraction(event, {
onMove: (moveEvent) => {
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 });
},
onStop: () => {
dragRef.current = null;
},
});
if (!started) return;
dragRef.current = {
tabId,
mode,
@@ -127,59 +176,9 @@ const FloatingWorkbenchWindows: React.FC = () => {
originW: bounds.width,
originH: bounds.height,
};
}, [focusDetachedWorkbenchTab, startManagedInteraction, updateDetachedWorkbenchBounds]);
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 (hasNativeDetachedWindowManager() || windowModels.length === 0) {
if (nativeWindowManagerAvailable || windowModels.length === 0) {
return null;
}

View File

@@ -385,6 +385,14 @@ vi.mock('./LogPanel', () => ({
),
}));
vi.mock('./DetachDragPreview', async () => {
const actual = await vi.importActual<typeof import('./DetachDragPreview')>('./DetachDragPreview');
return {
...actual,
default: () => null,
};
});
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
@@ -4529,3 +4537,286 @@ describe('QueryEditor external SQL save', () => {
},
);
});
type ResultTabTestListener = (event: Record<string, unknown>) => void;
const createResultTabTestEventTarget = () => {
const listeners = new Map<string, Set<ResultTabTestListener>>();
return {
addEventListener: vi.fn((type: string, listener: ResultTabTestListener) => {
const registered = listeners.get(type) ?? new Set<ResultTabTestListener>();
registered.add(listener);
listeners.set(type, registered);
}),
removeEventListener: vi.fn((type: string, listener: ResultTabTestListener) => {
listeners.get(type)?.delete(listener);
}),
dispatch(type: string, event: Record<string, unknown> = {}) {
for (const listener of [...(listeners.get(type) ?? [])]) {
listener({ type, ...event });
}
},
listenerCount(type: string) {
return listeners.get(type)?.size ?? 0;
},
};
};
const createResultTabPointerCaptureTarget = (throwOnRelease = false) => {
const eventTarget = createResultTabTestEventTarget();
const capturedPointers = new Set<number>();
return Object.assign(eventTarget, {
setPointerCapture: vi.fn((pointerId: number) => {
capturedPointers.add(pointerId);
}),
hasPointerCapture: vi.fn((pointerId: number) => capturedPointers.has(pointerId)),
releasePointerCapture: vi.fn((pointerId: number) => {
if (throwOnRelease) throw new Error('capture already released');
capturedPointers.delete(pointerId);
}),
});
};
describe('QueryEditorResultsPanel result-tab detach lifecycle', () => {
let renderer: ReactTestRenderer | null = null;
let windowTarget: ReturnType<typeof createResultTabTestEventTarget> & Record<string, any>;
let documentTarget: Record<string, any>;
let classNames: Set<string>;
let removeAllRanges: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
classNames = new Set<string>();
removeAllRanges = vi.fn();
windowTarget = Object.assign(createResultTabTestEventTarget(), {
screenX: 0,
screenY: 0,
outerWidth: 1200,
outerHeight: 800,
innerWidth: 1200,
innerHeight: 800,
getSelection: vi.fn(() => ({ rangeCount: 1, removeAllRanges })),
});
documentTarget = {
body: {
style: {
userSelect: 'text',
webkitUserSelect: 'auto',
},
},
documentElement: {
classList: {
add: vi.fn((className: string) => classNames.add(className)),
remove: vi.fn((className: string) => classNames.delete(className)),
contains: (className: string) => classNames.has(className),
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
vi.stubGlobal('window', windowTarget);
vi.stubGlobal('document', documentTarget);
});
afterEach(() => {
act(() => {
renderer?.unmount();
});
renderer = null;
vi.unstubAllGlobals();
vi.clearAllMocks();
});
const renderDetachableResultPanel = async (onOpenResultInWindow = vi.fn()) => {
await act(async () => {
renderer = create(
<QueryEditorResultsPanel
resultSets={[{
key: 'result-1',
sql: 'select 1',
rows: [{ value: 1 }],
columns: ['value'],
pkColumns: [],
readOnly: true,
}]}
activeResultKey="result-1"
isActive
loading={false}
executionError=""
sqlLogCount={0}
darkMode={false}
isV2Ui
currentDb="main"
currentConnectionId="conn-1"
toggleShortcutLabel=""
onActiveResultKeyChange={vi.fn()}
onHide={vi.fn()}
onCloseResult={vi.fn()}
onCloseOtherResultTabs={vi.fn()}
onCloseResultTabsToLeft={vi.fn()}
onCloseResultTabsToRight={vi.fn()}
onCloseAllResultTabs={vi.fn()}
onOpenResultInWindow={onOpenResultInWindow}
onReloadResult={vi.fn()}
onResultPageChange={vi.fn()}
onResultSort={vi.fn()}
onDiagnoseExecutionError={vi.fn()}
/>,
);
});
};
const beginResultTabDrag = (captureTarget = createResultTabPointerCaptureTarget()) => {
const resultTabLabel = renderer!.root.findAll((node) =>
typeof node.props?.onPointerDown === 'function'
&& String(node.props?.className || '').split(/\s+/).includes('query-result-tab-label'),
)[0];
act(() => {
resultTabLabel.props.onPointerDown({
button: 0,
buttons: 1,
isPrimary: true,
target: { closest: () => null },
currentTarget: captureTarget,
pointerId: 7,
clientX: 100,
clientY: 100,
screenX: 300,
screenY: 300,
});
});
return captureTarget;
};
it('restores selection state and removes global listeners when the window blurs', async () => {
const onOpenResultInWindow = vi.fn();
await renderDetachableResultPanel(onOpenResultInWindow);
const captureTarget = beginResultTabDrag();
act(() => {
windowTarget.dispatch('pointermove', {
pointerId: 7,
buttons: 1,
clientX: 120,
clientY: 130,
preventDefault: vi.fn(),
});
});
expect(documentTarget.body.style.userSelect).toBe('none');
expect(documentTarget.body.style.webkitUserSelect).toBe('none');
expect(classNames.has('gn-result-tab-detaching')).toBe(true);
expect(windowTarget.listenerCount('selectstart')).toBe(1);
expect(windowTarget.listenerCount('dragstart')).toBe(1);
expect(removeAllRanges).toHaveBeenCalled();
act(() => {
windowTarget.dispatch('blur');
windowTarget.dispatch('blur');
captureTarget.dispatch('lostpointercapture', { pointerId: 7 });
});
expect(documentTarget.body.style.userSelect).toBe('text');
expect(documentTarget.body.style.webkitUserSelect).toBe('auto');
expect(classNames.has('gn-result-tab-detaching')).toBe(false);
expect(windowTarget.listenerCount('pointermove')).toBe(0);
expect(windowTarget.listenerCount('pointerup')).toBe(0);
expect(windowTarget.listenerCount('pointercancel')).toBe(0);
expect(windowTarget.listenerCount('blur')).toBe(0);
expect(windowTarget.listenerCount('selectstart')).toBe(0);
expect(windowTarget.listenerCount('dragstart')).toBe(0);
expect(captureTarget.listenerCount('lostpointercapture')).toBe(0);
expect(captureTarget.releasePointerCapture).toHaveBeenCalledTimes(1);
expect(onOpenResultInWindow).not.toHaveBeenCalled();
});
it('ignores other pointers and cleans up when the active pointer loses capture', async () => {
await renderDetachableResultPanel();
const captureTarget = beginResultTabDrag();
act(() => {
windowTarget.dispatch('pointermove', {
pointerId: 9,
buttons: 0,
clientX: 180,
clientY: 180,
preventDefault: vi.fn(),
});
windowTarget.dispatch('pointerup', { pointerId: 9 });
captureTarget.dispatch('lostpointercapture', { pointerId: 9 });
});
expect(windowTarget.listenerCount('pointermove')).toBe(1);
expect(captureTarget.listenerCount('lostpointercapture')).toBe(1);
expect(captureTarget.releasePointerCapture).not.toHaveBeenCalled();
act(() => {
captureTarget.dispatch('lostpointercapture', { pointerId: 7 });
});
expect(windowTarget.listenerCount('pointermove')).toBe(0);
expect(windowTarget.listenerCount('pointerup')).toBe(0);
expect(windowTarget.listenerCount('pointercancel')).toBe(0);
expect(windowTarget.listenerCount('blur')).toBe(0);
expect(captureTarget.listenerCount('lostpointercapture')).toBe(0);
});
it('self-heals on buttons=0 even when releasing pointer capture throws', async () => {
const onOpenResultInWindow = vi.fn();
await renderDetachableResultPanel(onOpenResultInWindow);
const captureTarget = beginResultTabDrag(createResultTabPointerCaptureTarget(true));
expect(() => {
act(() => {
windowTarget.dispatch('pointermove', {
pointerId: 7,
buttons: 0,
clientX: 160,
clientY: 160,
preventDefault: vi.fn(),
});
});
}).not.toThrow();
expect(captureTarget.releasePointerCapture).toHaveBeenCalledWith(7);
expect(windowTarget.listenerCount('pointermove')).toBe(0);
expect(windowTarget.listenerCount('pointerup')).toBe(0);
expect(windowTarget.listenerCount('pointercancel')).toBe(0);
expect(windowTarget.listenerCount('blur')).toBe(0);
expect(captureTarget.listenerCount('lostpointercapture')).toBe(0);
expect(onOpenResultInWindow).not.toHaveBeenCalled();
});
it('restores active drag state when the result panel unmounts', async () => {
await renderDetachableResultPanel();
const captureTarget = beginResultTabDrag();
act(() => {
windowTarget.dispatch('pointermove', {
pointerId: 7,
buttons: 1,
clientX: 120,
clientY: 130,
preventDefault: vi.fn(),
});
});
expect(classNames.has('gn-result-tab-detaching')).toBe(true);
act(() => {
renderer?.unmount();
});
renderer = null;
expect(documentTarget.body.style.userSelect).toBe('text');
expect(documentTarget.body.style.webkitUserSelect).toBe('auto');
expect(classNames.has('gn-result-tab-detaching')).toBe(false);
expect(windowTarget.listenerCount('pointermove')).toBe(0);
expect(windowTarget.listenerCount('pointerup')).toBe(0);
expect(windowTarget.listenerCount('pointercancel')).toBe(0);
expect(windowTarget.listenerCount('blur')).toBe(0);
expect(windowTarget.listenerCount('selectstart')).toBe(0);
expect(windowTarget.listenerCount('dragstart')).toBe(0);
expect(captureTarget.listenerCount('lostpointercapture')).toBe(0);
expect(captureTarget.releasePointerCapture).toHaveBeenCalledWith(7);
});
});

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Dropdown, Tabs, Tooltip, message, type MenuProps } from 'antd';
import { BugOutlined, CloseOutlined, CopyOutlined, EyeInvisibleOutlined, RobotOutlined } from '@ant-design/icons';
@@ -173,6 +173,11 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
captureTarget: HTMLElement;
active: boolean;
} | null>(null);
const resultTabDragCleanupRef = useRef<((resetVisualState?: boolean) => void) | null>(null);
useEffect(() => () => {
resultTabDragCleanupRef.current?.(false);
}, []);
const resolveResultTabTitle = useCallback((key: string) => {
const index = resultSets.findIndex((item) => item.key === key);
@@ -186,8 +191,10 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
const handleResultTabPointerDown = useCallback((event: React.PointerEvent<HTMLElement>, key: string) => {
if (!onOpenResultInWindow || !shouldActivateResultTabDetachPointer(event)) return;
const openResultInWindow = onOpenResultInWindow;
resultTabDragCleanupRef.current?.();
const title = resolveResultTabTitle(key);
resultTabDragRef.current = {
const dragState = {
key,
title,
startX: event.clientX,
@@ -198,15 +205,12 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
captureTarget: event.currentTarget,
active: false,
};
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Some embedded WebViews do not expose pointer capture for tab labels.
}
resultTabDragRef.current = dragState;
const previousUserSelect = document.body.style.userSelect;
const previousWebkitUserSelect = (document.body.style as CSSStyleDeclaration & { webkitUserSelect?: string }).webkitUserSelect || '';
let selectionSuppressed = false;
let cleaned = false;
const clearNativeSelection = () => {
const selection = window.getSelection?.();
@@ -215,7 +219,12 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
}
};
const suppressTextSelection = () => {
function preventSelectStart(selectEvent: Event) {
selectEvent.preventDefault();
selectEvent.stopPropagation();
}
function suppressTextSelection() {
if (!selectionSuppressed) {
selectionSuppressed = true;
document.body.style.userSelect = 'none';
@@ -225,18 +234,19 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
window.addEventListener('dragstart', preventSelectStart, true);
}
clearNativeSelection();
};
}
const preventSelectStart = (selectEvent: Event) => {
selectEvent.preventDefault();
selectEvent.stopPropagation();
};
const clearListeners = () => {
const drag = resultTabDragRef.current;
function clearListeners(resetVisualState = true) {
if (cleaned) return;
cleaned = true;
if (resultTabDragCleanupRef.current === clearListeners) {
resultTabDragCleanupRef.current = null;
}
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
window.removeEventListener('pointercancel', handleUp);
window.removeEventListener('pointercancel', handleCancel);
window.removeEventListener('blur', handleWindowBlur);
dragState.captureTarget.removeEventListener('lostpointercapture', handleLostPointerCapture);
window.removeEventListener('selectstart', preventSelectStart, true);
window.removeEventListener('dragstart', preventSelectStart, true);
if (selectionSuppressed) {
@@ -244,17 +254,30 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
(document.body.style as CSSStyleDeclaration & { webkitUserSelect?: string }).webkitUserSelect = previousWebkitUserSelect;
document.documentElement.classList.remove('gn-result-tab-detaching');
}
if (drag?.captureTarget.hasPointerCapture?.(drag.pointerId)) {
drag.captureTarget.releasePointerCapture(drag.pointerId);
try {
if (dragState.captureTarget.hasPointerCapture?.(dragState.pointerId)) {
dragState.captureTarget.releasePointerCapture(dragState.pointerId);
}
} catch {
// Capture may already be gone after blur, cancellation, or unmount.
}
resultTabDragRef.current = null;
setDraggingResultKey(null);
setDetachDragPreview(null);
};
if (resultTabDragRef.current === dragState) {
resultTabDragRef.current = null;
}
if (resetVisualState) {
setDraggingResultKey(null);
setDetachDragPreview(null);
}
}
const handleMove = (moveEvent: PointerEvent) => {
function handleMove(moveEvent: PointerEvent) {
if (moveEvent.pointerId !== dragState.pointerId) return;
if (moveEvent.buttons === 0) {
clearListeners();
return;
}
const drag = resultTabDragRef.current;
if (!drag || drag.key !== key) return;
if (drag !== dragState) return;
const dx = moveEvent.clientX - drag.startX;
const dy = moveEvent.clientY - drag.startY;
if (!drag.active && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) {
@@ -273,11 +296,12 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
deltaY: dy,
}));
}
};
}
const handleUp = (upEvent: PointerEvent) => {
function handleUp(upEvent: PointerEvent) {
if (upEvent.pointerId !== dragState.pointerId) return;
const drag = resultTabDragRef.current;
if (!drag || drag.key !== key) {
if (drag !== dragState) {
clearListeners();
return;
}
@@ -302,13 +326,37 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
// 先清预览再打开真实窗口,避免叠两层
clearListeners();
if (shouldDetach) {
onOpenResultInWindow(key, resolveNativeDetachPreferredBounds(releaseScreenX, releaseScreenY));
openResultInWindow(key, resolveNativeDetachPreferredBounds(releaseScreenX, releaseScreenY));
}
};
}
function handleCancel(cancelEvent: PointerEvent) {
if (cancelEvent.pointerId === dragState.pointerId) {
clearListeners();
}
}
function handleWindowBlur() {
clearListeners();
}
function handleLostPointerCapture(lostEvent: Event) {
if ((lostEvent as PointerEvent).pointerId === dragState.pointerId) {
clearListeners();
}
}
resultTabDragCleanupRef.current = clearListeners;
window.addEventListener('pointermove', handleMove, { passive: false });
window.addEventListener('pointerup', handleUp);
window.addEventListener('pointercancel', handleUp);
window.addEventListener('pointercancel', handleCancel);
window.addEventListener('blur', handleWindowBlur);
dragState.captureTarget.addEventListener('lostpointercapture', handleLostPointerCapture);
try {
dragState.captureTarget.setPointerCapture(dragState.pointerId);
} catch {
// Some embedded WebViews do not expose pointer capture for tab labels.
}
}, [onOpenResultInWindow, resolveResultTabTitle]);
const shouldShowSqlLogTab = isV2Ui;

View File

@@ -0,0 +1,166 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import RedisCommandEditor from './RedisCommandEditor';
const storeState = vi.hoisted((): any => ({
connections: [{
id: 'redis-1',
name: 'redis',
config: { type: 'redis', host: '127.0.0.1', port: 6379 },
}],
theme: 'dark',
appearance: { enabled: true, opacity: 1, blur: 0, uiVersion: 'v2' },
}));
vi.mock('../store', () => ({
useStore: (selector: (state: typeof storeState) => any) => selector(storeState),
}));
vi.mock('./MonacoEditor', async () => {
const ReactModule = await import('react');
return {
default: () => ReactModule.createElement('div', { 'data-monaco-editor': 'true' }),
};
});
vi.mock('@ant-design/icons', async () => {
const ReactModule = await import('react');
const Icon = () => ReactModule.createElement('span');
return { ClearOutlined: Icon, PlayCircleOutlined: Icon };
});
vi.mock('antd', async () => {
const ReactModule = await import('react');
return {
Button: ({ children, ...props }: any) => ReactModule.createElement('button', props, children),
Space: ({ children }: any) => ReactModule.createElement('div', null, children),
message: { warning: vi.fn() },
};
});
type Listener = (event: any) => void;
class FakeEventTarget {
private listeners = new Map<string, Set<Listener>>();
addEventListener(type: string, listener: Listener) {
const listeners = this.listeners.get(type) ?? new Set<Listener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener) {
this.listeners.get(type)?.delete(listener);
}
dispatch(type: string, event: any = {}) {
for (const listener of [...(this.listeners.get(type) ?? [])]) {
listener(event);
}
}
listenerCount(type: string) {
return this.listeners.get(type)?.size ?? 0;
}
}
describe('RedisCommandEditor resize interaction cleanup', () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
let renderer: ReactTestRenderer | null = null;
let fakeWindow: FakeEventTarget;
let fakeDocument: FakeEventTarget & {
body: {
getAttribute: (name: string) => string | null;
style: { cursor: string; userSelect: string };
};
};
const beginResize = () => {
act(() => {
renderer?.root.findByProps({ 'data-redis-command-resizer': 'true' }).props.onMouseDown({
button: 0,
clientY: 300,
preventDefault: vi.fn(),
});
});
};
beforeEach(() => {
fakeWindow = new FakeEventTarget();
fakeDocument = Object.assign(new FakeEventTarget(), {
body: {
getAttribute: (name: string) => (name === 'data-ui-version' ? 'v2' : null),
style: { cursor: 'wait', userSelect: 'text' },
},
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: fakeWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: fakeDocument });
act(() => {
renderer = create(
<RedisCommandEditor connectionId="redis-1" redisDB={0} />,
{
createNodeMock: (element) => (
element.props['data-redis-command-editor'] === 'true'
? { clientHeight: 900, scrollIntoView: vi.fn() }
: { scrollIntoView: vi.fn() }
),
},
);
});
});
afterEach(() => {
act(() => renderer?.unmount());
renderer = null;
if (previousWindowDescriptor) {
Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
if (previousDocumentDescriptor) {
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'document');
}
});
it('restores exact body styles and removes listeners on window blur', () => {
beginResize();
expect(fakeDocument.body.style).toEqual({ cursor: 'row-resize', userSelect: 'none' });
expect(fakeWindow.listenerCount('blur')).toBe(1);
act(() => fakeWindow.dispatch('blur'));
expect(fakeDocument.body.style).toEqual({ cursor: 'wait', userSelect: 'text' });
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
});
it('self-heals when movement reports no pressed button', () => {
beginResize();
act(() => fakeDocument.dispatch('mousemove', { buttons: 0, clientY: 340 }));
expect(fakeDocument.body.style).toEqual({ cursor: 'wait', userSelect: 'text' });
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
});
it('restores interaction state when unmounted mid-resize', () => {
beginResize();
act(() => renderer?.unmount());
renderer = null;
expect(fakeDocument.body.style).toEqual({ cursor: 'wait', userSelect: 'text' });
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
});
});

View File

@@ -133,6 +133,12 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
// UI Layout state
const [editorHeight, setEditorHeight] = useState(250);
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null);
const dragBodyStyleRef = useRef<{ cursor: string; userSelect: string } | null>(null);
const dragListenersRef = useRef<{
blur: () => void;
move: (event: MouseEvent) => void;
up: () => void;
} | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const resultsEndRef = useRef<HTMLDivElement>(null);
@@ -325,16 +331,42 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
};
// Resizing logic
const handleDragStart = (e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = { startY: e.clientY, startHeight: editorHeight };
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
document.body.style.cursor = 'row-resize';
};
const detachDragListeners = useCallback(() => {
const listeners = dragListenersRef.current;
if (!listeners) return;
dragListenersRef.current = null;
if (typeof document !== 'undefined') {
document.removeEventListener('mousemove', listeners.move);
document.removeEventListener('mouseup', listeners.up);
}
if (typeof window !== 'undefined') {
window.removeEventListener('blur', listeners.blur);
}
}, []);
const restoreDragBodyStyles = useCallback(() => {
const previous = dragBodyStyleRef.current;
dragBodyStyleRef.current = null;
if (!previous || typeof document === 'undefined') return;
document.body.style.cursor = previous.cursor;
document.body.style.userSelect = previous.userSelect;
}, []);
const finishDrag = useCallback((layoutEditor = true) => {
dragRef.current = null;
detachDragListeners();
restoreDragBodyStyles();
if (layoutEditor && editorRef.current) {
editorRef.current.layout();
}
}, [detachDragListeners, restoreDragBodyStyles]);
const handleDragMove = useCallback((e: MouseEvent) => {
if (!dragRef.current) return;
if (e.buttons === 0) {
finishDrag();
return;
}
const delta = e.clientY - dragRef.current.startY;
let newHeight = dragRef.current.startHeight + delta;
@@ -350,17 +382,35 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
if (editorRef.current) {
editorRef.current.layout();
}
}, []);
}, [finishDrag]);
const handleDragEnd = useCallback(() => {
dragRef.current = null;
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
document.body.style.cursor = 'default';
if (editorRef.current) {
editorRef.current.layout();
}
}, [handleDragMove]);
const handleDragStart = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
e.preventDefault();
finishDrag(false);
dragRef.current = { startY: e.clientY, startHeight: editorHeight };
const handleDragEnd = () => finishDrag();
const handleWindowBlur = () => finishDrag();
dragListenersRef.current = {
blur: handleWindowBlur,
move: handleDragMove,
up: handleDragEnd,
};
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
window.addEventListener('blur', handleWindowBlur);
dragBodyStyleRef.current = {
cursor: document.body.style.cursor,
userSelect: document.body.style.userSelect,
};
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
}, [editorHeight, finishDrag, handleDragMove]);
useEffect(() => () => {
finishDrag(false);
}, [finishDrag]);
if (!connection) {
return <div style={{ padding: 20 }}>{tr('redis_command.state.connection_not_found')}</div>;

View File

@@ -0,0 +1,157 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import RedisResizableDivider from './RedisResizableDivider';
type Listener = (event: any) => void;
class FakeEventTarget {
private listeners = new Map<string, Set<Listener>>();
addEventListener(type: string, listener: Listener) {
const listeners = this.listeners.get(type) ?? new Set<Listener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener) {
this.listeners.get(type)?.delete(listener);
}
dispatch(type: string, event: any = {}) {
for (const listener of [...(this.listeners.get(type) ?? [])]) {
listener(event);
}
}
listenerCount(type: string) {
return this.listeners.get(type)?.size ?? 0;
}
}
class FakeOverlay {
isConnected = false;
style = { cssText: '' };
onRemove: (() => void) | null = null;
remove() {
this.isConnected = false;
this.onRemove?.();
}
}
describe('RedisResizableDivider interaction cleanup', () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
let fakeWindow: FakeEventTarget & { innerWidth: number };
let fakeDocument: FakeEventTarget & {
body: { appendChild: (overlay: FakeOverlay) => void };
createElement: () => FakeOverlay;
};
let overlays: FakeOverlay[];
let renderer: ReactTestRenderer | null = null;
let onResizeEnd: ReturnType<typeof vi.fn>;
const target = {
offsetWidth: 420,
parentElement: { offsetWidth: 1200 },
style: { width: '', flexBasis: '' },
};
const startResize = () => {
act(() => {
renderer?.root.findByType('div').props.onMouseDown({
clientX: 400,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
});
};
beforeEach(() => {
overlays = [];
fakeWindow = Object.assign(new FakeEventTarget(), { innerWidth: 1200 });
fakeDocument = Object.assign(new FakeEventTarget(), {
body: {
appendChild: (overlay: FakeOverlay) => {
overlay.isConnected = true;
overlay.onRemove = () => {
overlays = overlays.filter((candidate) => candidate !== overlay);
};
overlays.push(overlay);
},
},
createElement: () => new FakeOverlay(),
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: fakeWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: fakeDocument });
onResizeEnd = vi.fn();
act(() => {
renderer = create(
<RedisResizableDivider
targetRef={{ current: target } as React.RefObject<HTMLDivElement>}
onResizeEnd={onResizeEnd}
title="resize"
/>,
);
});
});
afterEach(() => {
act(() => renderer?.unmount());
renderer = null;
if (previousWindowDescriptor) {
Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
if (previousDocumentDescriptor) {
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'document');
}
});
it('removes the full-screen overlay and commits the width when the window blurs', () => {
startResize();
expect(overlays).toHaveLength(1);
act(() => fakeWindow.dispatch('blur'));
expect(overlays).toHaveLength(0);
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
expect(onResizeEnd).toHaveBeenCalledWith(420);
startResize();
expect(overlays).toHaveLength(1);
});
it('self-heals when mouse movement reports that the button was released', () => {
startResize();
act(() => fakeDocument.dispatch('mousemove', {
buttons: 0,
clientX: 520,
preventDefault: vi.fn(),
}));
expect(overlays).toHaveLength(0);
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(onResizeEnd).toHaveBeenCalledWith(420);
});
it('removes the overlay without updating state when unmounted mid-resize', () => {
startResize();
act(() => renderer?.unmount());
renderer = null;
expect(overlays).toHaveLength(0);
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
expect(onResizeEnd).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,94 @@
import React, { useEffect, useRef } from 'react';
type RedisResizableDividerProps = {
onResizeEnd: (newWidth: number) => void;
targetRef: React.RefObject<HTMLDivElement>;
minWidth?: number;
title: string;
};
// Direct DOM updates keep the Redis workbench responsive during a drag.
const RedisResizableDivider: React.FC<RedisResizableDividerProps> = ({
onResizeEnd,
targetRef,
minWidth = 300,
title,
}) => {
const abortInteractionRef = useRef<(() => void) | null>(null);
useEffect(() => () => {
abortInteractionRef.current?.();
}, []);
const handleMouseDown = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const target = targetRef.current;
if (!target) return;
abortInteractionRef.current?.();
const startX = event.clientX;
const startWidth = target.offsetWidth;
const containerWidth = target.parentElement?.offsetWidth || window.innerWidth;
const maxWidth = containerWidth - 350;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;cursor:col-resize;z-index:9999;';
document.body.appendChild(overlay);
let currentWidth = startWidth;
const cleanup = (commit: boolean) => {
if (abortInteractionRef.current !== abortInteraction) return;
abortInteractionRef.current = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', finishInteraction);
window.removeEventListener('blur', finishInteraction);
if (overlay.isConnected) {
overlay.remove();
}
if (commit) {
onResizeEnd(currentWidth);
}
};
const abortInteraction = () => cleanup(false);
const finishInteraction = () => cleanup(true);
const handleMouseMove = (moveEvent: MouseEvent) => {
if (moveEvent.buttons === 0) {
finishInteraction();
return;
}
moveEvent.preventDefault();
const delta = moveEvent.clientX - startX;
currentWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + delta));
target.style.width = `${currentWidth}px`;
target.style.flexBasis = `${currentWidth}px`;
};
abortInteractionRef.current = abortInteraction;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', finishInteraction);
window.addEventListener('blur', finishInteraction);
};
return (
<div
onMouseDown={handleMouseDown}
style={{
width: 5,
cursor: 'col-resize',
background: 'transparent',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
}}
title={title}
/>
);
};
export default RedisResizableDivider;

View File

@@ -35,6 +35,7 @@ import { isConnectionDataImportRestricted } from '../utils/connectionReadOnly';
import { t, type I18nParams } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import { APP_POPUP_Z_INDEX } from '../utils/overlayZIndex';
import RedisResizableDivider from './RedisResizableDivider';
const { Search } = Input;
@@ -68,72 +69,6 @@ type RedisImportPreview = {
keys: RedisKeyInfo[];
};
// Draggable divider uses direct DOM updates to avoid resize lag.
const ResizableDivider: React.FC<{
onResizeEnd: (newWidth: number) => void;
targetRef: React.RefObject<HTMLDivElement>;
minWidth?: number;
title: string;
}> = ({ onResizeEnd, targetRef, minWidth = 300, title }) => {
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const target = targetRef.current;
if (!target) return;
const startX = e.clientX;
const startWidth = target.offsetWidth;
const containerWidth = target.parentElement?.offsetWidth || window.innerWidth;
const maxWidth = containerWidth - 350; // Keep at least 350px for the right pane.
// Add an overlay to prevent text selection and other interactions.
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;cursor:col-resize;z-index:9999;';
document.body.appendChild(overlay);
let currentWidth = startWidth;
const handleMouseMove = (moveEvent: MouseEvent) => {
moveEvent.preventDefault();
const delta = moveEvent.clientX - startX;
currentWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + delta));
// Update DOM directly during drag without forcing React re-renders.
target.style.width = `${currentWidth}px`;
target.style.flexBasis = `${currentWidth}px`;
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.removeChild(overlay);
// Commit React state only after drag ends.
onResizeEnd(currentWidth);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
return (
<div
onMouseDown={handleMouseDown}
style={{
width: 5,
cursor: 'col-resize',
background: 'transparent',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
}}
title={title}
>
</div>
);
};
const getRedisScanLoadCount = (pattern: string, append: boolean): number => {
const normalizedPattern = pattern.trim() || '*';
if (normalizedPattern === '*') {
@@ -2281,7 +2216,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
</div>
{/* Resizable Divider */}
<ResizableDivider targetRef={leftPanelRef} onResizeEnd={setLeftPanelWidth} title={tr('redis_viewer.tooltip.resize_panels')} />
<RedisResizableDivider targetRef={leftPanelRef} onResizeEnd={setLeftPanelWidth} title={tr('redis_viewer.tooltip.resize_panels')} />
{/* Right: Value Viewer */}
<div className={isV2Ui ? 'gn-v2-redis-value-pane' : undefined} style={{ flex: 1, overflow: 'hidden', minWidth: 300 }}>

View File

@@ -0,0 +1,166 @@
import { describe, expect, it, vi } from 'vitest';
import { installTabDetachDragGuards } from './TabManager';
type Listener = (event: Record<string, unknown>) => void;
class FakeEventTarget {
private listeners = new Map<string, Set<Listener>>();
addEventListener(type: string, listener: Listener) {
const listeners = this.listeners.get(type) ?? new Set<Listener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener) {
this.listeners.get(type)?.delete(listener);
}
dispatch(type: string, event: Record<string, unknown> = {}) {
for (const listener of [...(this.listeners.get(type) ?? [])]) {
listener({ type, ...event });
}
}
listenerCount(type: string) {
return this.listeners.get(type)?.size ?? 0;
}
}
class FakePointerCaptureTarget extends FakeEventTarget {
private capturedPointers = new Set([7]);
hasPointerCapture(pointerId: number) {
return this.capturedPointers.has(pointerId);
}
releasePointerCapture(pointerId: number) {
this.capturedPointers.delete(pointerId);
}
}
class FakeClassList {
private classNames = new Set<string>();
add(className: string) {
this.classNames.add(className);
}
remove(className: string) {
this.classNames.delete(className);
}
contains(className: string) {
return this.classNames.has(className);
}
}
const installGuards = () => {
const windowTarget = new FakeEventTarget();
const captureTarget = new FakePointerCaptureTarget();
const rootClassList = new FakeClassList();
const onTerminalPointer = vi.fn();
const cancelDndDrag = vi.fn();
let active = true;
let removeGuards = () => {};
const onInterrupted = vi.fn(() => {
active = false;
removeGuards();
});
removeGuards = installTabDetachDragGuards({
windowTarget: windowTarget as unknown as Window,
captureTarget: captureTarget as unknown as HTMLElement,
rootClassList: rootClassList as unknown as DOMTokenList,
pointerId: 7,
isCurrent: () => active,
onTerminalPointer,
onInterrupted,
cancelDndDrag,
});
return {
cancelDndDrag,
captureTarget,
onInterrupted,
onTerminalPointer,
removeGuards,
rootClassList,
windowTarget,
};
};
describe('TabManager detach drag lifecycle', () => {
it.each([
['window blur', 'window', 'blur', {}],
['released primary button', 'window', 'pointermove', { buttons: 0, pointerId: 7 }],
['lost pointer capture', 'capture', 'lostpointercapture', { pointerId: 7 }],
] as const)('cancels dnd-kit and clears guards after %s', (_label, target, type, event) => {
const harness = installGuards();
expect(harness.rootClassList.contains('gn-workbench-tab-detaching')).toBe(true);
if (target === 'window') {
harness.windowTarget.dispatch(type, event);
} else {
harness.captureTarget.dispatch(type, event);
}
expect(harness.onInterrupted).toHaveBeenCalledOnce();
expect(harness.cancelDndDrag).toHaveBeenCalledOnce();
expect(harness.windowTarget.listenerCount('pointermove')).toBe(0);
expect(harness.windowTarget.listenerCount('pointerup')).toBe(0);
expect(harness.windowTarget.listenerCount('pointercancel')).toBe(0);
expect(harness.windowTarget.listenerCount('blur')).toBe(0);
expect(harness.captureTarget.listenerCount('lostpointercapture')).toBe(0);
expect(harness.captureTarget.hasPointerCapture(7)).toBe(false);
expect(harness.rootClassList.contains('gn-workbench-tab-detaching')).toBe(false);
});
it('keeps normal terminal pointer recording under dnd-kit control', () => {
const harness = installGuards();
harness.windowTarget.dispatch('pointerup', {
clientX: 320,
clientY: 48,
pointerId: 7,
screenX: 1320,
screenY: 88,
});
expect(harness.onTerminalPointer).toHaveBeenCalledWith({
clientX: 320,
clientY: 48,
screenX: 1320,
screenY: 88,
type: 'pointerup',
});
expect(harness.onInterrupted).not.toHaveBeenCalled();
expect(harness.cancelDndDrag).not.toHaveBeenCalled();
harness.removeGuards();
});
it('ignores other pointers and removes every listener on unmount cleanup', () => {
const harness = installGuards();
harness.windowTarget.dispatch('pointermove', { buttons: 0, pointerId: 9 });
harness.captureTarget.dispatch('lostpointercapture', { pointerId: 9 });
expect(harness.onInterrupted).not.toHaveBeenCalled();
harness.removeGuards();
harness.removeGuards();
expect(harness.windowTarget.listenerCount('pointermove')).toBe(0);
expect(harness.windowTarget.listenerCount('pointerup')).toBe(0);
expect(harness.windowTarget.listenerCount('pointercancel')).toBe(0);
expect(harness.windowTarget.listenerCount('blur')).toBe(0);
expect(harness.captureTarget.listenerCount('lostpointercapture')).toBe(0);
expect(harness.captureTarget.hasPointerCapture(7)).toBe(false);
expect(harness.rootClassList.contains('gn-workbench-tab-detaching')).toBe(false);
harness.windowTarget.dispatch('blur');
expect(harness.onInterrupted).not.toHaveBeenCalled();
expect(harness.cancelDndDrag).not.toHaveBeenCalled();
});
});

View File

@@ -567,6 +567,92 @@ export const handleTabDragPointerDown = (
handlePointerDown?.(event);
};
type TabDetachDragGuardOptions = {
windowTarget: EventTarget;
captureTarget: EventTarget | null;
rootClassList: Pick<DOMTokenList, 'add' | 'remove'>;
pointerId: number | null;
isCurrent: () => boolean;
onTerminalPointer: (pointer: NativeDetachTerminalPointer) => void;
onInterrupted: () => void;
cancelDndDrag: () => void;
};
export const installTabDetachDragGuards = ({
windowTarget,
captureTarget,
rootClassList,
pointerId,
isCurrent,
onTerminalPointer,
onInterrupted,
cancelDndDrag,
}: TabDetachDragGuardOptions): (() => void) => {
let removed = false;
const matchesPointer = (event: PointerEvent) => (
pointerId === null || event.pointerId === pointerId
);
const interrupt = () => {
if (removed || !isCurrent()) return;
try {
onInterrupted();
} finally {
cancelDndDrag();
}
};
const recordTerminalPointer = (event: Event) => {
const pointerEvent = event as PointerEvent;
if (removed || !isCurrent() || !matchesPointer(pointerEvent)) return;
onTerminalPointer({
type: event.type === 'pointercancel' ? 'pointercancel' : 'pointerup',
clientX: pointerEvent.clientX,
clientY: pointerEvent.clientY,
screenX: pointerEvent.screenX,
screenY: pointerEvent.screenY,
});
};
const handlePointerMove = (event: Event) => {
const pointerEvent = event as PointerEvent;
if (matchesPointer(pointerEvent) && pointerEvent.buttons === 0) {
interrupt();
}
};
const handleLostPointerCapture = (event: Event) => {
if (matchesPointer(event as PointerEvent)) {
interrupt();
}
};
const handleWindowBlur = () => interrupt();
windowTarget.addEventListener('pointermove', handlePointerMove, true);
windowTarget.addEventListener('pointerup', recordTerminalPointer, true);
windowTarget.addEventListener('pointercancel', recordTerminalPointer, true);
windowTarget.addEventListener('blur', handleWindowBlur);
captureTarget?.addEventListener('lostpointercapture', handleLostPointerCapture);
rootClassList.add('gn-workbench-tab-detaching');
return () => {
if (removed) return;
removed = true;
windowTarget.removeEventListener('pointermove', handlePointerMove, true);
windowTarget.removeEventListener('pointerup', recordTerminalPointer, true);
windowTarget.removeEventListener('pointercancel', recordTerminalPointer, true);
windowTarget.removeEventListener('blur', handleWindowBlur);
captureTarget?.removeEventListener('lostpointercapture', handleLostPointerCapture);
if (captureTarget && pointerId !== null) {
const pointerCaptureTarget = captureTarget as HTMLElement;
try {
if (pointerCaptureTarget.hasPointerCapture?.(pointerId)) {
pointerCaptureTarget.releasePointerCapture(pointerId);
}
} catch {
// Pointer capture may already be gone after blur, cancellation, or unmount.
}
}
rootClassList.remove('gn-workbench-tab-detaching');
};
};
const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
const tabId = String(node.key || '').trim();
const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId });
@@ -635,7 +721,7 @@ const TabManager: React.FC = React.memo(() => {
pointerId: number | null;
captureTarget: HTMLElement | null;
terminalPointer: NativeDetachTerminalPointer | null;
removeTerminalListeners: (() => void) | null;
removeDragGuards: (() => void) | null;
} | null>(null);
const suppressClickUntilRef = useRef<number>(0);
const sensors = useSensors(
@@ -840,16 +926,21 @@ const TabManager: React.FC = React.memo(() => {
}
};
const dispatchDndPointerCancel = useCallback(() => {
document.dispatchEvent(new Event('pointercancel', {
bubbles: true,
cancelable: true,
}));
}, []);
const clearDetachDragSession = useCallback(() => {
const session = detachDragSessionRef.current;
session?.removeTerminalListeners?.();
if (
session?.captureTarget
&& session.pointerId !== null
&& session.captureTarget.hasPointerCapture?.(session.pointerId)
) {
session?.removeDragGuards?.();
if (session?.captureTarget && session.pointerId !== null) {
try {
session.captureTarget.releasePointerCapture(session.pointerId);
if (session.captureTarget.hasPointerCapture?.(session.pointerId)) {
session.captureTarget.releasePointerCapture(session.pointerId);
}
} catch {
// Pointer capture may already have been released by the native WebView.
}
@@ -859,7 +950,16 @@ const TabManager: React.FC = React.memo(() => {
document.documentElement.classList.remove('gn-workbench-tab-detaching');
}, []);
useEffect(() => () => {
const hadActiveSession = detachDragSessionRef.current !== null;
clearDetachDragSession();
if (hadActiveSession) {
dispatchDndPointerCancel();
}
}, [clearDetachDragSession, dispatchDndPointerCancel]);
const handleDragStart = (event: DragStartEvent) => {
clearDetachDragSession();
const sourceId = String(event.active.id || '').trim();
setDraggingTabId(sourceId || null);
const tab = dockedTabs.find((item) => item.id === sourceId);
@@ -895,33 +995,27 @@ const TabManager: React.FC = React.memo(() => {
pointerId,
captureTarget,
terminalPointer: null as NativeDetachTerminalPointer | null,
removeTerminalListeners: null as (() => void) | null,
removeDragGuards: null as (() => void) | null,
}
: null;
detachDragSessionRef.current = session;
if (session) {
const recordTerminalPointer = (nativeEvent: PointerEvent) => {
if (
detachDragSessionRef.current !== session
|| (session.pointerId !== null && nativeEvent.pointerId !== session.pointerId)
) return;
session.terminalPointer = {
type: nativeEvent.type === 'pointercancel' ? 'pointercancel' : 'pointerup',
clientX: nativeEvent.clientX,
clientY: nativeEvent.clientY,
screenX: nativeEvent.screenX,
screenY: nativeEvent.screenY,
};
};
const removeTerminalListeners = () => {
window.removeEventListener('pointerup', recordTerminalPointer, true);
window.removeEventListener('pointercancel', recordTerminalPointer, true);
};
session.removeTerminalListeners = removeTerminalListeners;
window.addEventListener('pointerup', recordTerminalPointer, true);
window.addEventListener('pointercancel', recordTerminalPointer, true);
session.removeDragGuards = installTabDetachDragGuards({
windowTarget: window,
captureTarget: session.captureTarget,
rootClassList: document.documentElement.classList,
pointerId: session.pointerId,
isCurrent: () => detachDragSessionRef.current === session,
onTerminalPointer: (terminalPointer) => {
session.terminalPointer = terminalPointer;
},
onInterrupted: () => {
setDraggingTabId(null);
clearDetachDragSession();
},
cancelDndDrag: dispatchDndPointerCancel,
});
}
document.documentElement.classList.add('gn-workbench-tab-detaching');
};
const handleDragMove = (event: DragMoveEvent) => {

View File

@@ -30,9 +30,11 @@ describe('empty workbench layout', () => {
describe('workbench tab native detach drag', () => {
it('treats a captured native pointercancel as a possible cross-window release', () => {
expect(tabManagerSource).toContain(
"window.addEventListener('pointercancel', recordTerminalPointer, true)",
"windowTarget.addEventListener('pointercancel', recordTerminalPointer, true)",
);
expect(tabManagerSource).toContain('terminalPointer: session?.terminalPointer');
expect(tabManagerSource).toContain('shouldDetachAfterNativePointerCancel(release');
expect(tabManagerSource).toContain('const hadActiveSession = detachDragSessionRef.current !== null;');
expect(tabManagerSource).toContain('if (hadActiveSession) {\n dispatchDndPointerCancel();');
});
});

View File

@@ -551,7 +551,13 @@ const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, em
const resizeRafRef = useRef<number | null>(null);
const latestResizeXRef = useRef<number | null>(null);
const ghostRef = useRef<HTMLDivElement>(null);
const resizeListenerRef = useRef<{ move: ((e: MouseEvent) => void) | null; up: ((e: MouseEvent) => void) | null }>({
const resizeBodyStyleRef = useRef<{ cursor: string; userSelect: string } | null>(null);
const resizeListenerRef = useRef<{
blur: (() => void) | null;
move: ((e: MouseEvent) => void) | null;
up: ((e: MouseEvent) => void) | null;
}>({
blur: null,
move: null,
up: null,
});
@@ -797,6 +803,10 @@ const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, em
document.removeEventListener('mouseup', resizeListenerRef.current.up);
resizeListenerRef.current.up = null;
}
if (resizeListenerRef.current.blur) {
window.removeEventListener('blur', resizeListenerRef.current.blur);
resizeListenerRef.current.blur = null;
}
}, []);
const cleanupResizeState = useCallback(() => {
@@ -809,14 +819,41 @@ const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, em
if (ghostRef.current) {
ghostRef.current.style.display = 'none';
}
document.body.style.cursor = '';
document.body.style.userSelect = '';
const previousBodyStyle = resizeBodyStyleRef.current;
resizeBodyStyleRef.current = null;
if (previousBodyStyle) {
document.body.style.cursor = previousBodyStyle.cursor;
document.body.style.userSelect = previousBodyStyle.userSelect;
}
}, []);
const finishResize = useCallback((clientX?: number, commit = true) => {
const dragState = resizeDragRef.current;
const latestResizeX = latestResizeXRef.current;
detachResizeListeners();
cleanupResizeState();
if (commit && dragState) {
const finalClientX = Number.isFinite(clientX) ? clientX as number : latestResizeX ?? dragState.startX;
const newWidth = Math.max(50, dragState.startWidth + finalClientX - dragState.startX);
dragState.setter((prevColumns) => {
if (!prevColumns[dragState.index]) return prevColumns;
const nextColumns = [...prevColumns];
nextColumns[dragState.index] = {
...nextColumns[dragState.index],
width: newWidth,
};
return nextColumns;
});
}
}, [cleanupResizeState, detachResizeListeners]);
const createResizeStartHandler = useCallback((columns: any[], setter: React.Dispatch<React.SetStateAction<any[]>>) => (index: number) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
finishResize(undefined, false);
const startX = e.clientX;
const currentWidth = Number(columns[index]?.width || 200);
const containerLeft = shellRef.current?.getBoundingClientRect().left ?? 0;
@@ -829,51 +866,39 @@ const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, em
ghostRef.current.style.display = 'block';
}
detachResizeListeners();
const onMove = (event: MouseEvent) => {
if (!resizeDragRef.current) return;
latestResizeXRef.current = event.clientX;
if (event.buttons === 0) {
finishResize(event.clientX);
return;
}
if (resizeRafRef.current !== null) return;
resizeRafRef.current = requestAnimationFrame(flushResizeGhost);
};
const onUp = (event: MouseEvent) => finishResize(event.clientX);
const onBlur = () => finishResize();
const onUp = (event: MouseEvent) => {
if (resizeDragRef.current) {
const { startX: dragStartX, startWidth, index: dragIndex, setter: dragSetter } = resizeDragRef.current;
const deltaX = event.clientX - dragStartX;
const newWidth = Math.max(50, startWidth + deltaX);
dragSetter((prevColumns) => {
if (!prevColumns[dragIndex]) return prevColumns;
const nextColumns = [...prevColumns];
nextColumns[dragIndex] = {
...nextColumns[dragIndex],
width: newWidth,
};
return nextColumns;
});
}
detachResizeListeners();
cleanupResizeState();
};
resizeListenerRef.current = { move: onMove, up: onUp };
resizeListenerRef.current = { blur: onBlur, move: onMove, up: onUp };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
window.addEventListener('blur', onBlur);
resizeBodyStyleRef.current = {
cursor: document.body.style.cursor,
userSelect: document.body.style.userSelect,
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [cleanupResizeState, detachResizeListeners, flushResizeGhost]);
}, [finishResize, flushResizeGhost]);
const handleResizeStart = useMemo(() => createResizeStartHandler(tableColumns, setTableColumns), [createResizeStartHandler, tableColumns]);
const handleIndexResizeStart = useMemo(() => createResizeStartHandler(indexColumns, setIndexColumns), [createResizeStartHandler, indexColumns]);
useEffect(() => {
return () => {
detachResizeListeners();
cleanupResizeState();
finishResize(undefined, false);
};
}, [cleanupResizeState, detachResizeListeners]);
}, [finishResize]);
const clearMetadataLoading = () => {
setColumnsLoading(false);

View File

@@ -0,0 +1,197 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAIChatPanelResize } from './useAIChatPanelResize';
type Listener = (event: unknown) => void;
class FakeEventTarget {
private listeners = new Map<string, Set<Listener>>();
addEventListener(type: string, listener: Listener) {
const listeners = this.listeners.get(type) ?? new Set<Listener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener) {
this.listeners.get(type)?.delete(listener);
}
dispatch(type: string, event: unknown = {}) {
for (const listener of [...(this.listeners.get(type) ?? [])]) {
listener(event);
}
}
listenerCount(type: string) {
return this.listeners.get(type)?.size ?? 0;
}
}
describe('useAIChatPanelResize interaction cleanup', () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
let renderer: ReactTestRenderer | null = null;
let resize: ReturnType<typeof useAIChatPanelResize> | null = null;
let fakeWindow: FakeEventTarget;
let fakeDocument: FakeEventTarget & {
body: {
style: {
cursor: string;
pointerEvents: string;
userSelect: string;
};
};
};
const Harness = ({ attachPanel = true }: { attachPanel?: boolean }) => {
resize = useAIChatPanelResize({ width: 420, isV2Ui: true });
return attachPanel ? <div ref={resize.panelRef} /> : null;
};
const mountHarness = (attachPanel = true) => {
renderer = create(<Harness attachPanel={attachPanel} />, {
createNodeMock: () => ({
getBoundingClientRect: () => ({ top: 100, bottom: 700, left: 480 }),
}),
});
};
beforeEach(() => {
fakeWindow = new FakeEventTarget();
fakeDocument = Object.assign(new FakeEventTarget(), {
body: {
style: {
cursor: 'wait',
pointerEvents: 'auto',
userSelect: 'text',
},
},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: Object.assign(fakeWindow, { innerHeight: 900 }),
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: fakeDocument,
});
act(() => {
mountHarness();
});
});
afterEach(() => {
act(() => {
renderer?.unmount();
});
renderer = null;
resize = null;
if (previousWindowDescriptor) {
Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
if (previousDocumentDescriptor) {
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'document');
}
});
const beginResize = () => {
act(() => {
resize?.handleResizeStart({
clientX: 600,
preventDefault: vi.fn(),
} as unknown as React.MouseEvent);
});
};
it('ends resizing and restores global interaction styles when the window loses focus', () => {
beginResize();
expect(fakeDocument.body.style).toEqual({
cursor: 'col-resize',
pointerEvents: 'none',
userSelect: 'none',
});
expect(fakeDocument.listenerCount('mousemove')).toBe(1);
expect(fakeDocument.listenerCount('mouseup')).toBe(1);
expect(fakeWindow.listenerCount('blur')).toBe(1);
act(() => {
fakeWindow.dispatch('blur');
});
expect(resize?.isResizing).toBe(false);
expect(fakeDocument.body.style).toEqual({
cursor: 'wait',
pointerEvents: 'auto',
userSelect: 'text',
});
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
});
it('ends resizing when a move reports that the mouse button was released outside the window', () => {
beginResize();
act(() => {
fakeDocument.dispatch('mousemove', { buttons: 0, clientX: 560 });
});
expect(resize?.isResizing).toBe(false);
expect(fakeDocument.body.style).toEqual({
cursor: 'wait',
pointerEvents: 'auto',
userSelect: 'text',
});
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
});
it('does not lock global interaction when the panel ref is unavailable', () => {
act(() => {
renderer?.unmount();
mountHarness(false);
});
beginResize();
expect(resize?.isResizing).toBe(false);
expect(fakeDocument.body.style).toEqual({
cursor: 'wait',
pointerEvents: 'auto',
userSelect: 'text',
});
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
});
it('restores global interaction styles when an active resize unmounts without a terminal event', () => {
beginResize();
act(() => {
renderer?.unmount();
});
renderer = null;
expect(fakeDocument.body.style).toEqual({
cursor: 'wait',
pointerEvents: 'auto',
userSelect: 'text',
});
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
});
});

View File

@@ -28,13 +28,13 @@ export const useAIChatPanelResize = ({
const handleResizeStart = useCallback((event: ReactMouseEvent) => {
event.preventDefault();
if (!panelRef.current) {
return;
}
setIsResizing(true);
resizeStartX.current = event.clientX;
resizeStartWidth.current = panelWidth;
dragWidthRef.current = panelWidth;
if (!panelRef.current) {
return;
}
const rect = panelRef.current.getBoundingClientRect();
panelRect.current = {
top: rect.top,
@@ -49,7 +49,30 @@ export const useAIChatPanelResize = ({
}
let animationFrameId = 0;
let resizeFinished = false;
const previousBodyStyles = {
cursor: document.body.style.cursor,
pointerEvents: document.body.style.pointerEvents,
userSelect: document.body.style.userSelect,
};
const finishResize = () => {
if (resizeFinished) {
return;
}
resizeFinished = true;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
setIsResizing(false);
setPanelWidth(dragWidthRef.current);
onWidthChange?.(dragWidthRef.current);
};
const handleMouseMove = (event: MouseEvent) => {
if (event.buttons === 0) {
finishResize();
return;
}
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
@@ -68,17 +91,9 @@ export const useAIChatPanelResize = ({
});
};
const handleMouseUp = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
setIsResizing(false);
setPanelWidth(dragWidthRef.current);
onWidthChange?.(dragWidthRef.current);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mouseup', finishResize);
window.addEventListener('blur', finishResize);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.body.style.pointerEvents = 'none';
@@ -88,10 +103,11 @@ export const useAIChatPanelResize = ({
cancelAnimationFrame(animationFrameId);
}
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.body.style.pointerEvents = '';
document.removeEventListener('mouseup', finishResize);
window.removeEventListener('blur', finishResize);
document.body.style.cursor = previousBodyStyles.cursor;
document.body.style.userSelect = previousBodyStyles.userSelect;
document.body.style.pointerEvents = previousBodyStyles.pointerEvents;
};
}, [isResizing, isV2Ui, onWidthChange]);

View File

@@ -0,0 +1,236 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('antd', async () => {
const ReactModule = await import('react');
const modalResult = () => ({ update: vi.fn() });
const modalApi = {
info: modalResult,
success: modalResult,
error: modalResult,
warning: modalResult,
confirm: modalResult,
};
const Modal = Object.assign(
({ children }: { children?: React.ReactNode }) => ReactModule.createElement(ReactModule.Fragment, null, children),
{
...modalApi,
destroyAll: vi.fn(),
useModal: () => [modalApi, null],
},
);
return { Modal };
});
import { DraggableResizableModalFrame } from './ResizableDraggableModal';
type Listener = (event: any) => void;
class FakeEventTarget {
private listeners = new Map<string, Array<{ listener: Listener; once: boolean }>>();
addEventListener(type: string, listener: Listener, options?: boolean | AddEventListenerOptions) {
const listeners = this.listeners.get(type) ?? [];
listeners.push({ listener, once: typeof options === 'object' && options.once === true });
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener) {
this.listeners.set(type, (this.listeners.get(type) ?? []).filter((entry) => entry.listener !== listener));
}
dispatch(type: string, event: any = {}) {
for (const entry of [...(this.listeners.get(type) ?? [])]) {
if (entry.once) {
this.removeEventListener(type, entry.listener);
}
entry.listener(event);
}
}
listenerCount(type: string) {
return this.listeners.get(type)?.length ?? 0;
}
}
class FakeHTMLElement extends FakeEventTarget {
classList: { contains: (className: string) => boolean };
style = Object.assign({ width: '' }, {
removeProperty: (property: string) => {
if (property === 'width') this.style.width = '';
},
});
constructor(
private readonly kind: 'wrapper' | 'modal' | 'content' | 'header' | 'resize-handle',
private readonly classes: string[] = [],
) {
super();
this.classList = { contains: (className) => this.classes.includes(className) };
}
modal: FakeHTMLElement | null = null;
content: FakeHTMLElement | null = null;
closest(selector: string) {
if (this.kind === 'wrapper' && selector === '.ant-modal') return this.modal;
if (this.kind === 'header' && selector.includes('.ant-modal-header')) return this;
if (this.kind === 'resize-handle' && selector === '.gn-modal-resize-handle') return this;
return null;
}
querySelector(selector: string) {
return this.kind === 'wrapper' && selector === '.ant-modal-content' ? this.content : null;
}
getBoundingClientRect() {
if (this.kind === 'content') {
return { top: 100, right: 800, bottom: 600, left: 200, width: 600, height: 500 };
}
return { top: 100, right: 800, bottom: 600, left: 200, width: 600, height: 500 };
}
}
describe('DraggableResizableModalFrame interaction cleanup', () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousHTMLElementDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'HTMLElement');
let fakeWindow: FakeEventTarget & { innerWidth: number; innerHeight: number; setTimeout: typeof setTimeout };
let wrapper: FakeHTMLElement;
let header: FakeHTMLElement;
let resizeHandle: FakeHTMLElement;
let renderer: ReactTestRenderer | null = null;
const mount = (active = true) => {
renderer = create(
<DraggableResizableModalFrame
active={active}
draggable
resizable
minResizableWidth={360}
minResizableHeight={220}
>
<span>content</span>
</DraggableResizableModalFrame>,
{ createNodeMock: () => wrapper },
);
};
const pointerEvent = (target: FakeHTMLElement, buttons = 1) => ({
button: 0,
buttons,
clientX: 400,
clientY: 300,
target,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
const frameProps = () => renderer?.root.findByProps({ 'data-draggable': 'true' }).props;
beforeEach(() => {
vi.useFakeTimers();
const modal = new FakeHTMLElement('modal');
const content = new FakeHTMLElement('content');
wrapper = new FakeHTMLElement('wrapper');
wrapper.modal = modal;
wrapper.content = content;
header = new FakeHTMLElement('header');
resizeHandle = new FakeHTMLElement('resize-handle', ['gn-modal-resize-handle-south-east']);
fakeWindow = Object.assign(new FakeEventTarget(), {
innerWidth: 1200,
innerHeight: 900,
setTimeout: globalThis.setTimeout,
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: fakeWindow });
Object.defineProperty(globalThis, 'HTMLElement', { configurable: true, value: FakeHTMLElement });
act(() => mount());
});
afterEach(() => {
act(() => renderer?.unmount());
renderer = null;
vi.runOnlyPendingTimers();
vi.useRealTimers();
if (previousWindowDescriptor) {
Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
if (previousHTMLElementDescriptor) {
Object.defineProperty(globalThis, 'HTMLElement', previousHTMLElementDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'HTMLElement');
}
});
it('aborts a drag on blur without suppressing the next click and allows another drag', () => {
act(() => wrapper.dispatch('pointerdown', pointerEvent(header)));
expect(frameProps()?.['data-dragging']).toBe('true');
act(() => fakeWindow.dispatch('blur'));
expect(frameProps()?.['data-dragging']).toBe('false');
expect(fakeWindow.listenerCount('pointermove')).toBe(0);
expect(fakeWindow.listenerCount('mousemove')).toBe(0);
expect(fakeWindow.listenerCount('click')).toBe(0);
const click = { preventDefault: vi.fn(), stopPropagation: vi.fn() };
fakeWindow.dispatch('click', click);
expect(click.preventDefault).not.toHaveBeenCalled();
act(() => wrapper.dispatch('pointerdown', pointerEvent(header)));
expect(frameProps()?.['data-dragging']).toBe('true');
});
it('aborts resize when a move reports no pressed buttons', () => {
act(() => wrapper.dispatch('pointerdown', pointerEvent(resizeHandle)));
expect(frameProps()?.['data-resizing']).toBe('true');
act(() => fakeWindow.dispatch('pointermove', pointerEvent(resizeHandle, 0)));
expect(frameProps()?.['data-resizing']).toBe('false');
expect(fakeWindow.listenerCount('pointermove')).toBe(0);
expect(fakeWindow.listenerCount('mousemove')).toBe(0);
expect(fakeWindow.listenerCount('click')).toBe(0);
});
it('cleans an active interaction when the frame closes or unmounts', () => {
act(() => wrapper.dispatch('pointerdown', pointerEvent(header)));
act(() => {
renderer?.update(
<DraggableResizableModalFrame
active={false}
draggable
resizable
minResizableWidth={360}
minResizableHeight={220}
>
<span>content</span>
</DraggableResizableModalFrame>,
);
});
expect(fakeWindow.listenerCount('pointermove')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
act(() => renderer?.unmount());
renderer = null;
expect(fakeWindow.listenerCount('pointermove')).toBe(0);
expect(fakeWindow.listenerCount('click')).toBe(0);
});
it('suppresses only the synthetic click after a completed interaction', () => {
act(() => wrapper.dispatch('pointerdown', pointerEvent(header)));
act(() => fakeWindow.dispatch('pointerup'));
expect(fakeWindow.listenerCount('click')).toBe(1);
const syntheticClick = { preventDefault: vi.fn(), stopPropagation: vi.fn() };
fakeWindow.dispatch('click', syntheticClick);
expect(syntheticClick.preventDefault).toHaveBeenCalledOnce();
const nextClick = { preventDefault: vi.fn(), stopPropagation: vi.fn() };
fakeWindow.dispatch('click', nextClick);
expect(nextClick.preventDefault).not.toHaveBeenCalled();
});
});

View File

@@ -31,8 +31,10 @@ describe('ResizableDraggableModal guards', () => {
expect(modalSource).toContain("startResize('south-east', event);");
expect(modalSource).toContain("wrapperElement?.closest('.ant-modal')");
expect(modalSource).toContain("modalNode.style.width = `${size.width}px`;");
expect(modalSource).toContain("window.addEventListener('click', suppressInteractionClick, true);");
expect(modalSource).toContain("window.addEventListener('click', suppressInteractionClick, { capture: true, once: true });");
expect(modalSource).toContain("window.removeEventListener('click', suppressInteractionClick, true);");
expect(modalSource).toContain("window.addEventListener('blur', handleAbortDrag);");
expect(modalSource).toContain('if (moveEvent.buttons === 0)');
});
it('applies resized width and height to the underlying AntD modal nodes', () => {

View File

@@ -53,6 +53,8 @@ const DraggableResizableModalFrame: React.FC<DraggableResizableModalFrameProps>
}) => {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const activeInteractionRef = useRef<'drag' | 'resize' | null>(null);
const stopInteractionRef = useRef<((updateState?: boolean) => void) | null>(null);
const pendingClickCleanupRef = useRef<(() => void) | null>(null);
const [wrapperElement, setWrapperElement] = useState<HTMLDivElement | null>(null);
const [position, setPosition] = useState<ModalPosition>({ x: 0, y: 0 });
const [size, setSize] = useState<ModalSize>({});
@@ -61,6 +63,8 @@ const DraggableResizableModalFrame: React.FC<DraggableResizableModalFrameProps>
useEffect(() => {
if (!active) {
stopInteractionRef.current?.();
pendingClickCleanupRef.current?.();
setPosition({ x: 0, y: 0 });
setSize({});
setIsDragging(false);
@@ -69,9 +73,14 @@ const DraggableResizableModalFrame: React.FC<DraggableResizableModalFrameProps>
}
}, [active]);
useEffect(() => () => {
stopInteractionRef.current?.(false);
pendingClickCleanupRef.current?.();
}, []);
const startDrag = useCallback((event: PointerEvent | MouseEvent) => {
if (activeInteractionRef.current) return;
if (!draggable || event.button !== 0 || isInteractiveTarget(event.target)) return;
if (!active || !draggable || event.button !== 0 || isInteractiveTarget(event.target)) return;
const target = event.target instanceof HTMLElement ? event.target : null;
if (!target?.closest('.ant-modal-header, .ant-modal-title, .ant-modal-confirm-title')) return;
@@ -88,6 +97,7 @@ const DraggableResizableModalFrame: React.FC<DraggableResizableModalFrameProps>
const maxY = window.innerHeight - VIEWPORT_PADDING - rect.bottom + startPosition.y;
event.preventDefault();
pendingClickCleanupRef.current?.();
activeInteractionRef.current = 'drag';
setIsDragging(true);
@@ -96,36 +106,61 @@ const DraggableResizableModalFrame: React.FC<DraggableResizableModalFrameProps>
clickEvent.stopPropagation();
};
const removeClickSuppression = () => {
window.removeEventListener('click', suppressInteractionClick, true);
if (pendingClickCleanupRef.current === removeClickSuppression) {
pendingClickCleanupRef.current = null;
}
};
const handleMove = (moveEvent: PointerEvent | MouseEvent) => {
if (moveEvent.buttons === 0) {
abortDrag();
return;
}
const nextX = clamp(startPosition.x + moveEvent.clientX - startX, minX, maxX);
const nextY = clamp(startPosition.y + moveEvent.clientY - startY, minY, maxY);
setPosition({ x: nextX, y: nextY });
};
const stopDrag = () => {
const finishDrag = (completed: boolean, updateState = true) => {
if (stopInteractionRef.current !== abortDrag) return;
stopInteractionRef.current = null;
activeInteractionRef.current = null;
setIsDragging(false);
if (updateState) {
setIsDragging(false);
}
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('pointerup', stopDrag);
window.removeEventListener('mouseup', stopDrag);
window.removeEventListener('pointercancel', stopDrag);
window.setTimeout(() => {
window.removeEventListener('click', suppressInteractionClick, true);
}, 0);
window.removeEventListener('pointercancel', handleAbortDrag);
window.removeEventListener('blur', handleAbortDrag);
if (completed) {
pendingClickCleanupRef.current = removeClickSuppression;
window.addEventListener('click', suppressInteractionClick, { capture: true, once: true });
window.setTimeout(removeClickSuppression, 0);
} else {
removeClickSuppression();
}
};
const stopDrag = () => finishDrag(true);
const abortDrag = (updateState = true) => finishDrag(false, updateState);
const handleAbortDrag = () => abortDrag();
stopInteractionRef.current = abortDrag;
window.addEventListener('pointermove', handleMove);
window.addEventListener('mousemove', handleMove);
window.addEventListener('pointerup', stopDrag);
window.addEventListener('mouseup', stopDrag);
window.addEventListener('pointercancel', stopDrag);
window.addEventListener('click', suppressInteractionClick, true);
}, [draggable, position, wrapperElement]);
window.addEventListener('pointercancel', handleAbortDrag);
window.addEventListener('blur', handleAbortDrag);
}, [active, draggable, position, wrapperElement]);
const startResize = useCallback((direction: ResizeDirection, event: PointerEvent | MouseEvent) => {
if (activeInteractionRef.current) return;
if (!resizable || event.button !== 0) return;
if (!active || !resizable || event.button !== 0) return;
const modalContent = wrapperElement?.querySelector('.ant-modal-content');
const modalNode = wrapperElement?.closest('.ant-modal');
if (!(modalContent instanceof HTMLElement) || !(modalNode instanceof HTMLElement)) return;
@@ -139,6 +174,7 @@ const DraggableResizableModalFrame: React.FC<DraggableResizableModalFrameProps>
event.preventDefault();
event.stopPropagation();
pendingClickCleanupRef.current?.();
activeInteractionRef.current = 'resize';
setIsResizing(true);
setSize({
@@ -151,7 +187,18 @@ const DraggableResizableModalFrame: React.FC<DraggableResizableModalFrameProps>
clickEvent.stopPropagation();
};
const removeClickSuppression = () => {
window.removeEventListener('click', suppressInteractionClick, true);
if (pendingClickCleanupRef.current === removeClickSuppression) {
pendingClickCleanupRef.current = null;
}
};
const handleMove = (moveEvent: PointerEvent | MouseEvent) => {
if (moveEvent.buttons === 0) {
abortResize();
return;
}
const deltaX = moveEvent.clientX - startX;
const deltaY = moveEvent.clientY - startY;
setSize({
@@ -160,26 +207,40 @@ const DraggableResizableModalFrame: React.FC<DraggableResizableModalFrameProps>
});
};
const stopResize = () => {
const finishResize = (completed: boolean, updateState = true) => {
if (stopInteractionRef.current !== abortResize) return;
stopInteractionRef.current = null;
activeInteractionRef.current = null;
setIsResizing(false);
if (updateState) {
setIsResizing(false);
}
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('pointerup', stopResize);
window.removeEventListener('mouseup', stopResize);
window.removeEventListener('pointercancel', stopResize);
window.setTimeout(() => {
window.removeEventListener('click', suppressInteractionClick, true);
}, 0);
window.removeEventListener('pointercancel', handleAbortResize);
window.removeEventListener('blur', handleAbortResize);
if (completed) {
pendingClickCleanupRef.current = removeClickSuppression;
window.addEventListener('click', suppressInteractionClick, { capture: true, once: true });
window.setTimeout(removeClickSuppression, 0);
} else {
removeClickSuppression();
}
};
const stopResize = () => finishResize(true);
const abortResize = (updateState = true) => finishResize(false, updateState);
const handleAbortResize = () => abortResize();
stopInteractionRef.current = abortResize;
window.addEventListener('pointermove', handleMove);
window.addEventListener('mousemove', handleMove);
window.addEventListener('pointerup', stopResize);
window.addEventListener('mouseup', stopResize);
window.addEventListener('pointercancel', stopResize);
window.addEventListener('click', suppressInteractionClick, true);
}, [minResizableHeight, minResizableWidth, resizable, wrapperElement]);
window.addEventListener('pointercancel', handleAbortResize);
window.addEventListener('blur', handleAbortResize);
}, [active, minResizableHeight, minResizableWidth, resizable, wrapperElement]);
useEffect(() => {
const modalNode = wrapperElement?.closest('.ant-modal');
@@ -427,4 +488,6 @@ ResizableDraggableModal.useModal = ((...args: Parameters<typeof AntdModal.useMod
return [wrapHookModalApi(modalApi), contextHolder] as ReturnType<typeof AntdModal.useModal>;
}) as typeof AntdModal.useModal;
export { DraggableResizableModalFrame };
export default ResizableDraggableModal;

View File

@@ -36,6 +36,7 @@ import {
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
DETACHED_WINDOW_VIEWPORT_PADDING,
} from '../../utils/detachedWindow';
import { useManagedPointerInteraction } from '../../hooks/useManagedPointerInteraction';
import {
loadResultDiffDetachedBoundsMemory,
resolveResultDiffDetachedBounds,
@@ -119,6 +120,7 @@ const ResultDiffPanel: React.FC<ResultDiffPanelProps> = ({
originW: number;
originH: number;
} | null>(null);
const { startInteraction: startManagedInteraction } = useManagedPointerInteraction(open && detached);
const persistBoundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const persistBounds = useCallback((next: FloatingBounds) => {
@@ -642,6 +644,51 @@ const ResultDiffPanel: React.FC<ResultDiffPanelProps> = ({
event.preventDefault();
event.stopPropagation();
const current = boundsRef.current;
const started = startManagedInteraction(event, {
onMove: (moveEvent) => {
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,
);
applyBounds({
x: clamp(drag.originX + dx, DETACHED_WINDOW_VIEWPORT_PADDING, maxX),
y: clamp(drag.originY + dy, DETACHED_WINDOW_VIEWPORT_PADDING, maxY),
}, false);
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,
);
}
applyBounds({ width: nextW, height: nextH }, false);
},
onStop: () => {
dragRef.current = null;
saveResultDiffDetachedBoundsMemory(boundsRef.current);
},
});
if (!started) return;
dragRef.current = {
mode,
startX: event.clientX,
@@ -652,58 +699,7 @@ const ResultDiffPanel: React.FC<ResultDiffPanelProps> = ({
originH: current.height,
};
applyBounds((prev) => ({ ...prev, zIndex: prev.zIndex + 1 }), false);
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,
);
applyBounds({
x: clamp(drag.originX + dx, DETACHED_WINDOW_VIEWPORT_PADDING, maxX),
y: clamp(drag.originY + dy, DETACHED_WINDOW_VIEWPORT_PADDING, maxY),
}, false);
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,
);
}
applyBounds({ width: nextW, height: nextH }, false);
};
const stop = () => {
dragRef.current = null;
// 松手后持久化最终尺寸/位置
saveResultDiffDetachedBoundsMemory(boundsRef.current);
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
}, [applyBounds]);
}, [applyBounds, startManagedInteraction]);
if (!open) return null;

View File

@@ -0,0 +1,197 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useDataGridColumnResize } from './useDataGridColumnResize';
type Listener = (event: any) => void;
class FakeEventTarget {
private listeners = new Map<string, Set<Listener>>();
addEventListener(type: string, listener: Listener) {
const listeners = this.listeners.get(type) ?? new Set<Listener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener) {
this.listeners.get(type)?.delete(listener);
}
dispatch(type: string, event: any = {}) {
for (const listener of [...(this.listeners.get(type) ?? [])]) {
listener(event);
}
}
listenerCount(type: string) {
return this.listeners.get(type)?.size ?? 0;
}
}
describe('useDataGridColumnResize interaction cleanup', () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
const previousRequestAnimationFrameDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'requestAnimationFrame');
const previousCancelAnimationFrameDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'cancelAnimationFrame');
let renderer: ReactTestRenderer | null = null;
let resize: ReturnType<typeof useDataGridColumnResize> | null = null;
let fakeWindow: FakeEventTarget;
let fakeDocument: FakeEventTarget & { body: { style: { cursor: string; userSelect: string } } };
let ghost: { style: { display: string; transform: string } };
let scheduledFrames: Map<number, FrameRequestCallback>;
let nextFrameId: number;
let setColumnWidths: ReturnType<typeof vi.fn>;
const containerRef = {
current: {
clientWidth: 1000,
getBoundingClientRect: () => ({ left: 40 }),
querySelectorAll: () => [],
},
};
const Harness = () => {
resize = useDataGridColumnResize({
columnMetaMap: {},
columnMetaMapByLowerName: {},
columnWidths: { name: 120 },
containerRef,
dataTableDensity: 'comfortable',
densityParams: { dataFontSize: 13, defaultColumnWidth: 160 },
displayColumnNames: [],
displayData: [],
displayDataRef: { current: [] },
setColumnWidths,
showColumnComment: false,
showColumnType: false,
});
return null;
};
const beginResize = () => {
act(() => {
resize?.handleResizeStart('name')({
button: 0,
clientX: 200,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as React.MouseEvent);
});
};
const expectLastWidthUpdate = (width: number) => {
const lastCall = setColumnWidths.mock.calls[setColumnWidths.mock.calls.length - 1];
const update = lastCall?.[0] as ((previous: Record<string, number>) => Record<string, number>);
expect(update({ name: 120 })).toEqual({ name: width });
};
beforeEach(() => {
vi.useFakeTimers();
scheduledFrames = new Map();
nextFrameId = 1;
setColumnWidths = vi.fn();
fakeWindow = new FakeEventTarget();
fakeDocument = Object.assign(new FakeEventTarget(), {
body: { style: { cursor: 'crosshair', userSelect: 'text' } },
});
ghost = { style: { display: 'none', transform: '' } };
Object.defineProperty(globalThis, 'window', { configurable: true, value: fakeWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: fakeDocument });
Object.defineProperty(globalThis, 'requestAnimationFrame', {
configurable: true,
value: vi.fn((callback: FrameRequestCallback) => {
const frameId = nextFrameId++;
scheduledFrames.set(frameId, callback);
return frameId;
}),
});
Object.defineProperty(globalThis, 'cancelAnimationFrame', {
configurable: true,
value: vi.fn((frameId: number) => scheduledFrames.delete(frameId)),
});
act(() => {
renderer = create(<Harness />);
});
(resize!.ghostRef as React.MutableRefObject<any>).current = ghost;
});
afterEach(() => {
act(() => renderer?.unmount());
renderer = null;
resize = null;
vi.useRealTimers();
for (const [name, descriptor] of [
['window', previousWindowDescriptor],
['document', previousDocumentDescriptor],
['requestAnimationFrame', previousRequestAnimationFrameDescriptor],
['cancelAnimationFrame', previousCancelAnimationFrameDescriptor],
] as const) {
if (descriptor) {
Object.defineProperty(globalThis, name, descriptor);
} else {
Reflect.deleteProperty(globalThis, name);
}
}
});
it('restores body styles, hides the ghost, and commits on window blur', () => {
beginResize();
act(() => fakeDocument.dispatch('mousemove', { buttons: 1, clientX: 230 }));
expect(fakeDocument.body.style).toEqual({ cursor: 'col-resize', userSelect: 'none' });
expect(ghost.style.display).toBe('block');
expect(fakeWindow.listenerCount('blur')).toBe(1);
act(() => fakeWindow.dispatch('blur'));
expectLastWidthUpdate(150);
expect(scheduledFrames.size).toBe(0);
expect(ghost.style.display).toBe('none');
expect(fakeDocument.body.style).toEqual({ cursor: 'crosshair', userSelect: 'text' });
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
act(() => {
vi.advanceTimersByTime(100);
});
expect(resize?.isResizingRef.current).toBe(false);
});
it('self-heals when movement reports no pressed button', () => {
beginResize();
act(() => fakeDocument.dispatch('mousemove', { buttons: 0, clientX: 260 }));
expectLastWidthUpdate(180);
expect(ghost.style.display).toBe('none');
expect(fakeDocument.body.style).toEqual({ cursor: 'crosshair', userSelect: 'text' });
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
});
it('cancels pending RAF and gate work without committing when unmounted mid-resize', () => {
beginResize();
act(() => fakeDocument.dispatch('mousemove', { buttons: 1, clientX: 230 }));
expect(scheduledFrames.size).toBe(1);
act(() => renderer?.unmount());
renderer = null;
expect(scheduledFrames.size).toBe(0);
expect(cancelAnimationFrame).toHaveBeenCalledTimes(1);
expect(resize?.isResizingRef.current).toBe(false);
expect(ghost.style.display).toBe('none');
expect(fakeDocument.body.style).toEqual({ cursor: 'crosshair', userSelect: 'text' });
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
expect(setColumnWidths).not.toHaveBeenCalled();
});
});

View File

@@ -8,6 +8,11 @@ const ROW_NUMBER_MIN_WIDTH = 28;
const ROW_NUMBER_MAX_WIDTH = 120;
type UseDataGridColumnResizeContext = Record<string, any>;
type ColumnResizeListeners = {
blur: () => void;
move: (event: MouseEvent) => void;
up: (event: MouseEvent) => void;
};
export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) => {
const {
@@ -35,7 +40,12 @@ export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) =>
const resizeRafRef = useRef<number | null>(null);
const latestClientXRef = useRef<number | null>(null);
const isResizingRef = useRef(false);
const resizeGateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resizeBodyStyleRef = useRef<{ cursor: string; userSelect: string } | null>(null);
const resizeListenersRef = useRef<ColumnResizeListeners | null>(null);
const setColumnWidthsRef = useRef(setColumnWidths);
const autoFitCanvasRef = useRef<HTMLCanvasElement | null>(null);
setColumnWidthsRef.current = setColumnWidths;
const flushGhostPosition = useCallback(() => {
resizeRafRef.current = null;
@@ -45,10 +55,72 @@ export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) =>
ghostRef.current.style.transform = `translateX(${relativeLeft}px)`;
}, []);
const detachResizeListeners = useCallback(() => {
const listeners = resizeListenersRef.current;
if (!listeners) return;
resizeListenersRef.current = null;
if (typeof document !== 'undefined') {
document.removeEventListener('mousemove', listeners.move);
document.removeEventListener('mouseup', listeners.up);
}
if (typeof window !== 'undefined') {
window.removeEventListener('blur', listeners.blur);
}
}, []);
const restoreResizeBodyStyles = useCallback(() => {
const previous = resizeBodyStyleRef.current;
resizeBodyStyleRef.current = null;
if (!previous || typeof document === 'undefined') return;
document.body.style.cursor = previous.cursor;
document.body.style.userSelect = previous.userSelect;
}, []);
const finishResize = useCallback((clientX?: number, commit = true, deferGateReset = true) => {
const dragState = draggingRef.current;
const latestClientX = latestClientXRef.current;
draggingRef.current = null;
if (resizeRafRef.current !== null) {
cancelAnimationFrame(resizeRafRef.current);
resizeRafRef.current = null;
}
latestClientXRef.current = null;
if (ghostRef.current) {
ghostRef.current.style.display = 'none';
}
detachResizeListeners();
restoreResizeBodyStyles();
if (resizeGateTimeoutRef.current !== null) {
clearTimeout(resizeGateTimeoutRef.current);
resizeGateTimeoutRef.current = null;
}
if (deferGateReset) {
resizeGateTimeoutRef.current = setTimeout(() => {
resizeGateTimeoutRef.current = null;
isResizingRef.current = false;
}, 100);
} else {
isResizingRef.current = false;
}
if (commit && dragState) {
const finalClientX = Number.isFinite(clientX) ? clientX as number : latestClientX ?? dragState.startX;
const deltaX = finalClientX - dragState.startX;
const isRowNumberColumn = dragState.key === GONAVI_ROW_NUMBER_COLUMN_KEY;
const minWidth = isRowNumberColumn ? ROW_NUMBER_MIN_WIDTH : 50;
const maxWidth = isRowNumberColumn ? ROW_NUMBER_MAX_WIDTH : Number.POSITIVE_INFINITY;
const newWidth = Math.min(maxWidth, Math.max(minWidth, dragState.startWidth + deltaX));
setColumnWidthsRef.current((prev: Record<string, number>) => ({ ...prev, [dragState.key]: newWidth }));
}
}, [detachResizeListeners, restoreResizeBodyStyles]);
const handleResizeStart = useCallback((key: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
finishResize(undefined, false, false);
isResizingRef.current = true;
const startX = e.clientX;
@@ -70,11 +142,38 @@ export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) =>
ghostRef.current.style.display = 'block';
}
document.addEventListener('mousemove', handleResizeMove);
document.addEventListener('mouseup', handleResizeStop);
const handleMove = (event: MouseEvent) => {
if (!draggingRef.current) return;
latestClientXRef.current = event.clientX;
if (event.buttons === 0) {
finishResize(event.clientX);
return;
}
if (resizeRafRef.current !== null) return;
resizeRafRef.current = requestAnimationFrame(flushGhostPosition);
};
const handleUp = (event: MouseEvent) => finishResize(event.clientX);
const handleBlur = () => finishResize();
resizeListenersRef.current = {
blur: handleBlur,
move: handleMove,
up: handleUp,
};
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleUp);
window.addEventListener('blur', handleBlur);
resizeBodyStyleRef.current = {
cursor: document.body.style.cursor,
userSelect: document.body.style.userSelect,
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [columnWidths, dataTableDensity]);
}, [columnWidths, containerRef, dataTableDensity, finishResize, flushGhostPosition]);
useEffect(() => () => {
finishResize(undefined, false, false);
}, [finishResize]);
const measureTextWidth = useCallback((text: string, font: string) => {
if (typeof document === 'undefined') {
@@ -179,42 +278,6 @@ export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) =>
autoFitColumnWidth(key, headerEl);
}, [autoFitColumnWidth, setColumnWidths]);
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!draggingRef.current) return;
latestClientXRef.current = e.clientX;
if (resizeRafRef.current !== null) return;
resizeRafRef.current = requestAnimationFrame(flushGhostPosition);
}, [flushGhostPosition]);
const handleResizeStop = useCallback((e: MouseEvent) => {
if (!draggingRef.current) return;
const { startX, startWidth, key } = draggingRef.current;
const deltaX = e.clientX - startX;
const isRowNumberColumn = key === GONAVI_ROW_NUMBER_COLUMN_KEY;
const minWidth = isRowNumberColumn ? ROW_NUMBER_MIN_WIDTH : 50;
const maxWidth = isRowNumberColumn ? ROW_NUMBER_MAX_WIDTH : Number.POSITIVE_INFINITY;
const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth + deltaX));
setColumnWidths((prev: Record<string, number>) => ({ ...prev, [key]: newWidth }));
if (resizeRafRef.current !== null) {
cancelAnimationFrame(resizeRafRef.current);
resizeRafRef.current = null;
}
latestClientXRef.current = null;
if (ghostRef.current) ghostRef.current.style.display = 'none';
document.removeEventListener('mousemove', handleResizeMove);
document.removeEventListener('mouseup', handleResizeStop);
document.body.style.cursor = '';
document.body.style.userSelect = '';
draggingRef.current = null;
setTimeout(() => {
isResizingRef.current = false;
}, 100);
}, [handleResizeMove, setColumnWidths]);
return {
autoFitColumnWidth,
ghostRef,

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAppLogPanelResize } from './useAppLogPanelResize';
type Listener = (event: any) => void;
class FakeEventTarget {
private listeners = new Map<string, Set<Listener>>();
addEventListener(type: string, listener: Listener) {
const listeners = this.listeners.get(type) ?? new Set<Listener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener) {
this.listeners.get(type)?.delete(listener);
}
dispatch(type: string, event: any = {}) {
for (const listener of [...(this.listeners.get(type) ?? [])]) {
listener(event);
}
}
listenerCount(type: string) {
return this.listeners.get(type)?.size ?? 0;
}
}
describe('useAppLogPanelResize interaction cleanup', () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
let renderer: ReactTestRenderer | null = null;
let resize: ReturnType<typeof useAppLogPanelResize> | null = null;
let fakeWindow: FakeEventTarget;
let fakeDocument: FakeEventTarget;
let ghost: { style: { display: string; top: string } };
const Harness = () => {
resize = useAppLogPanelResize();
return null;
};
const beginResize = () => {
act(() => {
resize?.handleLogResizeStart({
button: 0,
clientY: 500,
preventDefault: vi.fn(),
} as unknown as React.MouseEvent);
});
};
beforeEach(() => {
fakeWindow = new FakeEventTarget();
fakeDocument = new FakeEventTarget();
ghost = { style: { display: 'none', top: '' } };
Object.defineProperty(globalThis, 'window', { configurable: true, value: fakeWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: fakeDocument });
act(() => {
renderer = create(<Harness />);
});
(resize!.logGhostRef as React.MutableRefObject<any>).current = ghost;
});
afterEach(() => {
act(() => renderer?.unmount());
renderer = null;
resize = null;
if (previousWindowDescriptor) {
Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
if (previousDocumentDescriptor) {
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'document');
}
});
it('hides the guide and commits the last height when the window blurs', () => {
beginResize();
act(() => fakeDocument.dispatch('mousemove', { buttons: 1, clientY: 450 }));
expect(ghost.style.top).toBe('450px');
expect(fakeWindow.listenerCount('blur')).toBe(1);
act(() => fakeWindow.dispatch('blur'));
expect(resize?.logPanelHeight).toBe(250);
expect(ghost.style.display).toBe('none');
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
});
it('self-heals when movement reports no pressed button', () => {
beginResize();
act(() => fakeDocument.dispatch('mousemove', { buttons: 0, clientY: 475 }));
expect(resize?.logPanelHeight).toBe(225);
expect(ghost.style.display).toBe('none');
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
});
it('removes listeners and hides the guide without updating state on unmount', () => {
beginResize();
act(() => renderer?.unmount());
renderer = null;
expect(resize?.logPanelHeight).toBe(200);
expect(ghost.style.display).toBe('none');
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
});
});

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
const LOG_PANEL_TOOLBAR_HEIGHT = 32;
const LOG_PANEL_SINGLE_ROW_HEIGHT = 39;
@@ -6,11 +6,19 @@ const LOG_PANEL_MIN_VISIBLE_ROWS = 1;
const LOG_PANEL_MIN_HEIGHT = LOG_PANEL_TOOLBAR_HEIGHT + (LOG_PANEL_SINGLE_ROW_HEIGHT * LOG_PANEL_MIN_VISIBLE_ROWS);
const LOG_PANEL_MAX_HEIGHT = 800;
type LogResizeListeners = {
blur: () => void;
move: (event: MouseEvent) => void;
up: (event: MouseEvent) => void;
};
export const useAppLogPanelResize = () => {
const [logPanelHeight, setLogPanelHeight] = useState(Math.max(200, LOG_PANEL_MIN_HEIGHT));
const [isLogPanelOpen, setIsLogPanelOpen] = useState(false);
const logResizeRef = useRef<{ startY: number; startHeight: number } | null>(null);
const logGhostRef = useRef<HTMLDivElement>(null);
const logResizeListenersRef = useRef<LogResizeListeners | null>(null);
const latestMouseYRef = useRef(0);
const handleToggleLogPanel = useCallback(() => {
setIsLogPanelOpen((prev) => !prev);
@@ -20,44 +28,79 @@ export const useAppLogPanelResize = () => {
setIsLogPanelOpen(false);
}, []);
const handleLogResizeStart = (e: React.MouseEvent) => {
const detachLogResizeListeners = useCallback(() => {
const listeners = logResizeListenersRef.current;
if (!listeners) return;
logResizeListenersRef.current = null;
if (typeof document !== 'undefined') {
document.removeEventListener('mousemove', listeners.move);
document.removeEventListener('mouseup', listeners.up);
}
if (typeof window !== 'undefined') {
window.removeEventListener('blur', listeners.blur);
}
}, []);
const finishLogResize = useCallback((clientY?: number, commit = true) => {
const dragState = logResizeRef.current;
logResizeRef.current = null;
if (logGhostRef.current) {
logGhostRef.current.style.display = 'none';
}
detachLogResizeListeners();
if (commit && dragState) {
const finalMouseY = Number.isFinite(clientY) ? clientY as number : latestMouseYRef.current;
const delta = dragState.startY - finalMouseY;
const newHeight = Math.max(
LOG_PANEL_MIN_HEIGHT,
Math.min(LOG_PANEL_MAX_HEIGHT, dragState.startHeight + delta),
);
setLogPanelHeight(newHeight);
}
}, [detachLogResizeListeners]);
const handleLogResizeStart = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
e.preventDefault();
finishLogResize(undefined, false);
logResizeRef.current = { startY: e.clientY, startHeight: logPanelHeight };
latestMouseYRef.current = e.clientY;
if (logGhostRef.current) {
logGhostRef.current.style.top = `${e.clientY}px`;
logGhostRef.current.style.display = 'block';
}
document.addEventListener('mousemove', handleLogResizeMove);
document.addEventListener('mouseup', handleLogResizeUp);
};
const handleMove = (event: MouseEvent) => {
if (!logResizeRef.current) return;
latestMouseYRef.current = event.clientY;
if (event.buttons === 0) {
finishLogResize(event.clientY);
return;
}
if (logGhostRef.current) {
logGhostRef.current.style.top = `${event.clientY}px`;
}
};
const handleUp = (event: MouseEvent) => finishLogResize(event.clientY);
const handleBlur = () => finishLogResize();
const handleLogResizeMove = (e: MouseEvent) => {
if (!logResizeRef.current) return;
if (logGhostRef.current) {
logGhostRef.current.style.top = `${e.clientY}px`;
}
};
logResizeListenersRef.current = {
blur: handleBlur,
move: handleMove,
up: handleUp,
};
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleUp);
window.addEventListener('blur', handleBlur);
}, [finishLogResize, logPanelHeight]);
const handleLogResizeUp = (e: MouseEvent) => {
if (logResizeRef.current) {
const delta = logResizeRef.current.startY - e.clientY;
const newHeight = Math.max(
LOG_PANEL_MIN_HEIGHT,
Math.min(LOG_PANEL_MAX_HEIGHT, logResizeRef.current.startHeight + delta),
);
setLogPanelHeight(newHeight);
}
if (logGhostRef.current) {
logGhostRef.current.style.display = 'none';
}
logResizeRef.current = null;
document.removeEventListener('mousemove', handleLogResizeMove);
document.removeEventListener('mouseup', handleLogResizeUp);
};
useEffect(() => () => {
finishLogResize(undefined, false);
}, [finishLogResize]);
return {
handleCloseLogPanel,

View File

@@ -0,0 +1,204 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAppSidebarResize } from './useAppSidebarResize';
type Listener = (event: any) => void;
class FakeEventTarget {
private listeners = new Map<string, Set<Listener>>();
addEventListener(type: string, listener: Listener) {
const listeners = this.listeners.get(type) ?? new Set<Listener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener) {
this.listeners.get(type)?.delete(listener);
}
dispatch(type: string, event: any = {}) {
for (const listener of [...(this.listeners.get(type) ?? [])]) {
listener(event);
}
}
listenerCount(type: string) {
return this.listeners.get(type)?.size ?? 0;
}
}
class FakeHTMLElement {
getBoundingClientRect() {
return { right: 240, width: 240 };
}
}
describe('useAppSidebarResize interaction cleanup', () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
const previousHTMLElementDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'HTMLElement');
const previousRequestAnimationFrameDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'requestAnimationFrame');
const previousCancelAnimationFrameDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'cancelAnimationFrame');
let renderer: ReactTestRenderer | null = null;
let resize: ReturnType<typeof useAppSidebarResize> | null = null;
let fakeWindow: FakeEventTarget & { getComputedStyle: () => { minWidth: string; maxWidth: string }; innerWidth: number };
let fakeDocument: FakeEventTarget & {
body: {
style: {
cursor: string;
userSelect: string;
webkitUserSelect: string;
};
};
};
let ghost: { style: { display: string; left: string } };
let scheduledFrames: Map<number, FrameRequestCallback>;
let nextFrameId: number;
let setSidebarWidth: ReturnType<typeof vi.fn>;
const Harness = () => {
resize = useAppSidebarResize({
effectiveUiScale: 1,
setSidebarWidth,
sidebarWidth: 240,
});
return null;
};
const beginResize = () => {
act(() => {
resize?.handleSidebarMouseDown({
button: 0,
clientX: 200,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as React.MouseEvent);
});
};
beforeEach(() => {
scheduledFrames = new Map();
nextFrameId = 1;
setSidebarWidth = vi.fn();
fakeWindow = Object.assign(new FakeEventTarget(), {
getComputedStyle: () => ({ minWidth: '180px', maxWidth: '600px' }),
innerWidth: 1200,
});
fakeDocument = Object.assign(new FakeEventTarget(), {
body: {
style: {
cursor: 'wait',
userSelect: 'text',
webkitUserSelect: 'auto',
},
},
});
ghost = { style: { display: 'none', left: '' } };
Object.defineProperty(globalThis, 'window', { configurable: true, value: fakeWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: fakeDocument });
Object.defineProperty(globalThis, 'HTMLElement', { configurable: true, value: FakeHTMLElement });
Object.defineProperty(globalThis, 'requestAnimationFrame', {
configurable: true,
value: vi.fn((callback: FrameRequestCallback) => {
const frameId = nextFrameId++;
scheduledFrames.set(frameId, callback);
return frameId;
}),
});
Object.defineProperty(globalThis, 'cancelAnimationFrame', {
configurable: true,
value: vi.fn((frameId: number) => scheduledFrames.delete(frameId)),
});
act(() => {
renderer = create(<Harness />);
});
(resize!.siderRef as React.MutableRefObject<any>).current = new FakeHTMLElement();
(resize!.ghostRef as React.MutableRefObject<any>).current = ghost;
});
afterEach(() => {
act(() => renderer?.unmount());
renderer = null;
resize = null;
for (const [name, descriptor] of [
['window', previousWindowDescriptor],
['document', previousDocumentDescriptor],
['HTMLElement', previousHTMLElementDescriptor],
['requestAnimationFrame', previousRequestAnimationFrameDescriptor],
['cancelAnimationFrame', previousCancelAnimationFrameDescriptor],
] as const) {
if (descriptor) {
Object.defineProperty(globalThis, name, descriptor);
} else {
Reflect.deleteProperty(globalThis, name);
}
}
});
it('restores the exact body styles and removes listeners when the window blurs', () => {
beginResize();
expect(fakeDocument.body.style).toEqual({
cursor: 'col-resize',
userSelect: 'none',
webkitUserSelect: 'none',
});
expect(ghost.style.display).toBe('block');
expect(fakeWindow.listenerCount('blur')).toBe(1);
act(() => fakeWindow.dispatch('blur'));
expect(fakeDocument.body.style).toEqual({
cursor: 'wait',
userSelect: 'text',
webkitUserSelect: 'auto',
});
expect(ghost.style.display).toBe('none');
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
expect(setSidebarWidth).toHaveBeenCalledWith(240);
});
it('self-heals and commits the last width when movement reports no pressed button', () => {
beginResize();
act(() => fakeDocument.dispatch('mousemove', { buttons: 0, clientX: 260 }));
expect(setSidebarWidth).toHaveBeenCalledWith(300);
expect(ghost.style.display).toBe('none');
expect(fakeDocument.body.style.cursor).toBe('wait');
expect(fakeDocument.body.style.userSelect).toBe('text');
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
});
it('cancels pending work and restores interaction state when unmounted mid-resize', () => {
beginResize();
act(() => fakeDocument.dispatch('mousemove', { buttons: 1, clientX: 250 }));
expect(scheduledFrames.size).toBe(1);
act(() => renderer?.unmount());
renderer = null;
expect(scheduledFrames.size).toBe(0);
expect(cancelAnimationFrame).toHaveBeenCalledTimes(1);
expect(ghost.style.display).toBe('none');
expect(fakeDocument.body.style).toEqual({
cursor: 'wait',
userSelect: 'text',
webkitUserSelect: 'auto',
});
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
expect(fakeWindow.listenerCount('blur')).toBe(0);
expect(setSidebarWidth).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import {
SIDEBAR_RESIZE_MAX_WIDTH,
SIDEBAR_RESIZE_MIN_WIDTH,
@@ -11,6 +11,11 @@ type SidebarResizeDragState = SidebarResizeBounds & {
startWidth: number;
startGuideLeft: number;
};
type SidebarResizeListeners = {
blur: () => void;
move: (event: MouseEvent) => void;
up: (event: MouseEvent) => void;
};
const parseCssPixelValue = (value: string | null | undefined): number | null => {
const parsed = Number.parseFloat(String(value || ''));
@@ -50,10 +55,26 @@ export const useAppSidebarResize = ({
const ghostRef = useRef<HTMLDivElement>(null);
const siderRef = useRef<HTMLDivElement | null>(null);
const sidebarDragBodyStyleRef = useRef<{ cursor: string; userSelect: string; webkitUserSelect: string } | null>(null);
const sidebarResizeListenersRef = useRef<SidebarResizeListeners | null>(null);
const latestMouseX = useRef<number>(0);
const setSidebarWidthRef = useRef(setSidebarWidth);
setSidebarWidthRef.current = setSidebarWidth;
const sidebarResizeHandleWidth = Math.max(16, Math.round(16 * effectiveUiScale));
const restoreSidebarDragBodyStyles = () => {
const detachSidebarResizeListeners = useCallback(() => {
const listeners = sidebarResizeListenersRef.current;
if (!listeners) return;
sidebarResizeListenersRef.current = null;
if (typeof document !== 'undefined') {
document.removeEventListener('mousemove', listeners.move);
document.removeEventListener('mouseup', listeners.up);
}
if (typeof window !== 'undefined') {
window.removeEventListener('blur', listeners.blur);
}
}, []);
const restoreSidebarDragBodyStyles = useCallback(() => {
if (!sidebarDragBodyStyleRef.current || typeof document === 'undefined') {
sidebarDragBodyStyleRef.current = null;
return;
@@ -62,11 +83,36 @@ export const useAppSidebarResize = ({
const previous = sidebarDragBodyStyleRef.current;
document.body.style.cursor = previous.cursor;
document.body.style.userSelect = previous.userSelect;
(document.body.style as any).WebkitUserSelect = previous.webkitUserSelect;
document.body.style.webkitUserSelect = previous.webkitUserSelect;
sidebarDragBodyStyleRef.current = null;
};
}, []);
const handleSidebarMouseDown = (e: React.MouseEvent) => {
const finishSidebarResize = useCallback((clientX?: number, commit = true) => {
const dragState = sidebarDragRef.current;
sidebarDragRef.current = null;
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
if (ghostRef.current) {
ghostRef.current.style.display = 'none';
}
detachSidebarResizeListeners();
restoreSidebarDragBodyStyles();
if (commit && dragState) {
const finalMouseX = Number.isFinite(clientX) ? clientX as number : latestMouseX.current;
const delta = finalMouseX - dragState.startX;
setSidebarWidthRef.current(clampSidebarResizeWidth(
dragState.startWidth + delta,
dragState,
));
}
}, [detachSidebarResizeListeners, restoreSidebarDragBodyStyles]);
const handleSidebarMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) {
e.preventDefault();
e.stopPropagation();
@@ -76,15 +122,17 @@ export const useAppSidebarResize = ({
e.preventDefault();
e.stopPropagation();
finishSidebarResize(undefined, false);
if (typeof document !== 'undefined') {
sidebarDragBodyStyleRef.current = {
cursor: document.body.style.cursor,
userSelect: document.body.style.userSelect,
webkitUserSelect: (document.body.style as any).WebkitUserSelect || '',
webkitUserSelect: document.body.style.webkitUserSelect,
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
(document.body.style as any).WebkitUserSelect = 'none';
document.body.style.webkitUserSelect = 'none';
}
const siderRect = siderRef.current?.getBoundingClientRect();
@@ -104,49 +152,41 @@ export const useAppSidebarResize = ({
...resizeBounds,
};
latestMouseX.current = e.clientX;
document.addEventListener('mousemove', handleSidebarMouseMove);
document.addEventListener('mouseup', handleSidebarMouseUp);
};
const handleSidebarMouseMove = (e: MouseEvent) => {
if (!sidebarDragRef.current) return;
const handleMove = (event: MouseEvent) => {
if (!sidebarDragRef.current) return;
latestMouseX.current = event.clientX;
if (event.buttons === 0) {
finishSidebarResize(event.clientX);
return;
}
if (rafRef.current !== null) return;
latestMouseX.current = e.clientX;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
if (!sidebarDragRef.current || !ghostRef.current) return;
const { startX, startWidth, startGuideLeft, minWidth, maxWidth } = sidebarDragRef.current;
const delta = latestMouseX.current - startX;
const newWidth = clampSidebarResizeWidth(startWidth + delta, { minWidth, maxWidth });
ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;
});
};
const handleUp = (event: MouseEvent) => finishSidebarResize(event.clientX);
const handleBlur = () => finishSidebarResize();
if (rafRef.current) return;
sidebarResizeListenersRef.current = {
blur: handleBlur,
move: handleMove,
up: handleUp,
};
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleUp);
window.addEventListener('blur', handleBlur);
}, [finishSidebarResize, sidebarWidth]);
rafRef.current = requestAnimationFrame(() => {
if (!sidebarDragRef.current || !ghostRef.current) return;
const { startX, startWidth, startGuideLeft, minWidth, maxWidth } = sidebarDragRef.current;
const delta = latestMouseX.current - startX;
const newWidth = clampSidebarResizeWidth(startWidth + delta, { minWidth, maxWidth });
ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;
rafRef.current = null;
});
};
const handleSidebarMouseUp = (e: MouseEvent) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
if (sidebarDragRef.current) {
const { startX, startWidth, minWidth, maxWidth } = sidebarDragRef.current;
const delta = e.clientX - startX;
const newWidth = clampSidebarResizeWidth(startWidth + delta, { minWidth, maxWidth });
setSidebarWidth(newWidth);
}
if (ghostRef.current) {
ghostRef.current.style.display = 'none';
}
restoreSidebarDragBodyStyles();
sidebarDragRef.current = null;
document.removeEventListener('mousemove', handleSidebarMouseMove);
document.removeEventListener('mouseup', handleSidebarMouseUp);
};
useEffect(() => () => {
finishSidebarResize(undefined, false);
}, [finishSidebarResize]);
return {
ghostRef,

View File

@@ -0,0 +1,83 @@
import { useCallback, useEffect, useRef, type PointerEvent as ReactPointerEvent } from 'react';
type ManagedPointerInteractionOptions = {
onMove: (event: PointerEvent) => void;
onStop?: () => void;
};
export const useManagedPointerInteraction = (active = true) => {
const stopInteractionRef = useRef<(() => void) | null>(null);
const stopInteraction = useCallback(() => {
stopInteractionRef.current?.();
}, []);
useEffect(() => {
if (!active) {
stopInteraction();
}
}, [active, stopInteraction]);
useEffect(() => stopInteraction, [stopInteraction]);
const startInteraction = useCallback((
event: ReactPointerEvent,
options: ManagedPointerInteractionOptions,
): boolean => {
if (!active || event.button !== 0) return false;
stopInteraction();
const pointerId = event.pointerId;
const captureTarget = event.currentTarget;
const handleMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
if (moveEvent.buttons === 0) {
stop();
return;
}
options.onMove(moveEvent);
};
const stop = (stopEvent?: PointerEvent) => {
if (stopEvent && stopEvent.pointerId !== pointerId) return;
if (stopInteractionRef.current !== stop) return;
stopInteractionRef.current = null;
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
window.removeEventListener('blur', handleWindowBlur);
captureTarget.removeEventListener('lostpointercapture', handleLostPointerCapture);
try {
if (captureTarget.hasPointerCapture(pointerId)) {
captureTarget.releasePointerCapture(pointerId);
}
} catch {
// Capture may already be gone after blur, cancellation, or unmount.
}
options.onStop?.();
};
const handleWindowBlur = () => stop();
const handleLostPointerCapture = (lostEvent: Event) => {
if ((lostEvent as PointerEvent).pointerId === pointerId) {
stop();
}
};
stopInteractionRef.current = stop;
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
window.addEventListener('blur', handleWindowBlur);
captureTarget.addEventListener('lostpointercapture', handleLostPointerCapture);
try {
captureTarget.setPointerCapture(pointerId);
} catch {
// Some embedded WebViews can remove the source element during pointerdown.
}
return true;
}, [active, stopInteraction]);
return { startInteraction, stopInteraction };
};