mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 00:33:28 +08:00
🐛 fix(ui): 修复拖拽与缩放残留导致的交互锁死
- 统一管理 pointer capture、全局监听器与选择状态生命周期 - 在失焦、捕获丢失、按键释放和组件卸载时自动清理交互状态 - 覆盖 AI、查询结果、工作台与结果比对浮窗拖拽缩放 - 修复 Redis、表设计、通用弹窗、数据网格和侧栏的残留监听 - 增加异常释放、重复终止和卸载清理等交互回归测试
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
166
frontend/src/components/RedisCommandEditor.interaction.test.tsx
Normal file
166
frontend/src/components/RedisCommandEditor.interaction.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
157
frontend/src/components/RedisResizableDivider.test.tsx
Normal file
157
frontend/src/components/RedisResizableDivider.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
94
frontend/src/components/RedisResizableDivider.tsx
Normal file
94
frontend/src/components/RedisResizableDivider.tsx
Normal 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;
|
||||
@@ -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 }}>
|
||||
|
||||
166
frontend/src/components/TabManager.drag-lifecycle.test.ts
Normal file
166
frontend/src/components/TabManager.drag-lifecycle.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
197
frontend/src/components/ai/useAIChatPanelResize.test.tsx
Normal file
197
frontend/src/components/ai/useAIChatPanelResize.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
125
frontend/src/hooks/useAppLogPanelResize.test.tsx
Normal file
125
frontend/src/hooks/useAppLogPanelResize.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
204
frontend/src/hooks/useAppSidebarResize.test.tsx
Normal file
204
frontend/src/hooks/useAppSidebarResize.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
83
frontend/src/hooks/useManagedPointerInteraction.ts
Normal file
83
frontend/src/hooks/useManagedPointerInteraction.ts
Normal 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 };
|
||||
};
|
||||
Reference in New Issue
Block a user