diff --git a/frontend/src/components/FloatingAIChatWindow.interaction.test.tsx b/frontend/src/components/FloatingAIChatWindow.interaction.test.tsx new file mode 100644 index 00000000..0f706c0b --- /dev/null +++ b/frontend/src/components/FloatingAIChatWindow.interaction.test.tsx @@ -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>(); + + addEventListener(type: string, listener: Listener) { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: string, listener: Listener) { + this.listeners.get(type)?.delete(listener); + } + + dispatch(type: string, event: Record = {}) { + 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(); + + 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) => ( + + ), + ConfigProvider: ({ children }: { children?: React.ReactNode }) => <>{children}, + Spin: () => , +})); + +vi.mock('./AIChatPanel', () => ({ + default: ({ onWindowDragStart }: { onWindowDragStart?: (event: React.PointerEvent) => void }) => ( +
+
+
+ ), +})); + +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( + , + ); + 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); + }); +}); diff --git a/frontend/src/components/FloatingAIChatWindow.tsx b/frontend/src/components/FloatingAIChatWindow.tsx index 4ddc0e06..b885df83 100644 --- a/frontend/src/components/FloatingAIChatWindow.tsx +++ b/frontend/src/components/FloatingAIChatWindow.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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; } diff --git a/frontend/src/components/FloatingQueryResultWindows.tsx b/frontend/src/components/FloatingQueryResultWindows.tsx index d4bb36a5..ca40b2db 100644 --- a/frontend/src/components/FloatingQueryResultWindows.tsx +++ b/frontend/src/components/FloatingQueryResultWindows.tsx @@ -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; } diff --git a/frontend/src/components/FloatingWorkbenchWindows.tsx b/frontend/src/components/FloatingWorkbenchWindows.tsx index 27624eb4..1cee2cf4 100644 --- a/frontend/src/components/FloatingWorkbenchWindows.tsx +++ b/frontend/src/components/FloatingWorkbenchWindows.tsx @@ -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; } diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index c11d52ba..33e136a2 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -385,6 +385,14 @@ vi.mock('./LogPanel', () => ({ ), })); +vi.mock('./DetachDragPreview', async () => { + const actual = await vi.importActual('./DetachDragPreview'); + return { + ...actual, + default: () => null, + }; +}); + vi.mock('@ant-design/icons', () => { const Icon = () => ; return { @@ -4529,3 +4537,286 @@ describe('QueryEditor external SQL save', () => { }, ); }); + +type ResultTabTestListener = (event: Record) => void; + +const createResultTabTestEventTarget = () => { + const listeners = new Map>(); + return { + addEventListener: vi.fn((type: string, listener: ResultTabTestListener) => { + const registered = listeners.get(type) ?? new Set(); + registered.add(listener); + listeners.set(type, registered); + }), + removeEventListener: vi.fn((type: string, listener: ResultTabTestListener) => { + listeners.get(type)?.delete(listener); + }), + dispatch(type: string, event: Record = {}) { + 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(); + 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 & Record; + let documentTarget: Record; + let classNames: Set; + let removeAllRanges: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + classNames = new Set(); + 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( + , + ); + }); + }; + + 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); + }); +}); diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx index 44f69533..579ce4da 100644 --- a/frontend/src/components/QueryEditorResultsPanel.tsx +++ b/frontend/src/components/QueryEditorResultsPanel.tsx @@ -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 = ({ 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 = ({ const handleResultTabPointerDown = useCallback((event: React.PointerEvent, 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 = ({ 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 = ({ } }; - 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 = ({ 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 = ({ (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 = ({ 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 = ({ // 先清预览再打开真实窗口,避免叠两层 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; diff --git a/frontend/src/components/RedisCommandEditor.interaction.test.tsx b/frontend/src/components/RedisCommandEditor.interaction.test.tsx new file mode 100644 index 00000000..e58eadf8 --- /dev/null +++ b/frontend/src/components/RedisCommandEditor.interaction.test.tsx @@ -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>(); + + addEventListener(type: string, listener: Listener) { + const listeners = this.listeners.get(type) ?? new Set(); + 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( + , + { + 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); + }); +}); diff --git a/frontend/src/components/RedisCommandEditor.tsx b/frontend/src/components/RedisCommandEditor.tsx index 2a8c9765..e663261f 100644 --- a/frontend/src/components/RedisCommandEditor.tsx +++ b/frontend/src/components/RedisCommandEditor.tsx @@ -133,6 +133,12 @@ const RedisCommandEditor: React.FC = ({ 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(null); const resultsEndRef = useRef(null); @@ -325,16 +331,42 @@ const RedisCommandEditor: React.FC = ({ 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 = ({ 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
{tr('redis_command.state.connection_not_found')}
; diff --git a/frontend/src/components/RedisResizableDivider.test.tsx b/frontend/src/components/RedisResizableDivider.test.tsx new file mode 100644 index 00000000..33bba165 --- /dev/null +++ b/frontend/src/components/RedisResizableDivider.test.tsx @@ -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>(); + + addEventListener(type: string, listener: Listener) { + const listeners = this.listeners.get(type) ?? new Set(); + 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; + 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( + } + 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(); + }); +}); diff --git a/frontend/src/components/RedisResizableDivider.tsx b/frontend/src/components/RedisResizableDivider.tsx new file mode 100644 index 00000000..b60604ff --- /dev/null +++ b/frontend/src/components/RedisResizableDivider.tsx @@ -0,0 +1,94 @@ +import React, { useEffect, useRef } from 'react'; + +type RedisResizableDividerProps = { + onResizeEnd: (newWidth: number) => void; + targetRef: React.RefObject; + minWidth?: number; + title: string; +}; + +// Direct DOM updates keep the Redis workbench responsive during a drag. +const RedisResizableDivider: React.FC = ({ + 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 ( +
+ ); +}; + +export default RedisResizableDivider; diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index b5546a24..8649414f 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -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; - 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 ( -
-
- ); -}; - const getRedisScanLoadCount = (pattern: string, append: boolean): number => { const normalizedPattern = pattern.trim() || '*'; if (normalizedPattern === '*') { @@ -2281,7 +2216,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => {
{/* Resizable Divider */} - + {/* Right: Value Viewer */}
diff --git a/frontend/src/components/TabManager.drag-lifecycle.test.ts b/frontend/src/components/TabManager.drag-lifecycle.test.ts new file mode 100644 index 00000000..07995ce7 --- /dev/null +++ b/frontend/src/components/TabManager.drag-lifecycle.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { installTabDetachDragGuards } from './TabManager'; + +type Listener = (event: Record) => void; + +class FakeEventTarget { + private listeners = new Map>(); + + addEventListener(type: string, listener: Listener) { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: string, listener: Listener) { + this.listeners.get(type)?.delete(listener); + } + + dispatch(type: string, event: Record = {}) { + 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(); + + 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(); + }); +}); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index f0f5d629..149f65bc 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -567,6 +567,92 @@ export const handleTabDragPointerDown = ( handlePointerDown?.(event); }; +type TabDetachDragGuardOptions = { + windowTarget: EventTarget; + captureTarget: EventTarget | null; + rootClassList: Pick; + 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 = ({ 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(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) => { diff --git a/frontend/src/components/TabManager.workbench-layout.test.ts b/frontend/src/components/TabManager.workbench-layout.test.ts index cb8b5955..7c956a76 100644 --- a/frontend/src/components/TabManager.workbench-layout.test.ts +++ b/frontend/src/components/TabManager.workbench-layout.test.ts @@ -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();'); }); }); diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 737b0282..a8f4ed6f 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -551,7 +551,13 @@ const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, em const resizeRafRef = useRef(null); const latestResizeXRef = useRef(null); const ghostRef = useRef(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>) => (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); diff --git a/frontend/src/components/ai/useAIChatPanelResize.test.tsx b/frontend/src/components/ai/useAIChatPanelResize.test.tsx new file mode 100644 index 00000000..0489aac4 --- /dev/null +++ b/frontend/src/components/ai/useAIChatPanelResize.test.tsx @@ -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>(); + + addEventListener(type: string, listener: Listener) { + const listeners = this.listeners.get(type) ?? new Set(); + 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 | 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 ?
: null; + }; + + const mountHarness = (attachPanel = true) => { + renderer = create(, { + 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); + }); +}); diff --git a/frontend/src/components/ai/useAIChatPanelResize.ts b/frontend/src/components/ai/useAIChatPanelResize.ts index c2139b99..4da5a6e7 100644 --- a/frontend/src/components/ai/useAIChatPanelResize.ts +++ b/frontend/src/components/ai/useAIChatPanelResize.ts @@ -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]); diff --git a/frontend/src/components/common/ResizableDraggableModal.interaction.test.tsx b/frontend/src/components/common/ResizableDraggableModal.interaction.test.tsx new file mode 100644 index 00000000..af3239d8 --- /dev/null +++ b/frontend/src/components/common/ResizableDraggableModal.interaction.test.tsx @@ -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>(); + + 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( + + content + , + { 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( + + content + , + ); + }); + 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(); + }); +}); diff --git a/frontend/src/components/common/ResizableDraggableModal.test.ts b/frontend/src/components/common/ResizableDraggableModal.test.ts index be132a96..4a9eb618 100644 --- a/frontend/src/components/common/ResizableDraggableModal.test.ts +++ b/frontend/src/components/common/ResizableDraggableModal.test.ts @@ -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', () => { diff --git a/frontend/src/components/common/ResizableDraggableModal.tsx b/frontend/src/components/common/ResizableDraggableModal.tsx index d5ef9a09..8215a303 100644 --- a/frontend/src/components/common/ResizableDraggableModal.tsx +++ b/frontend/src/components/common/ResizableDraggableModal.tsx @@ -53,6 +53,8 @@ const DraggableResizableModalFrame: React.FC }) => { const wrapperRef = useRef(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(null); const [position, setPosition] = useState({ x: 0, y: 0 }); const [size, setSize] = useState({}); @@ -61,6 +63,8 @@ const DraggableResizableModalFrame: React.FC useEffect(() => { if (!active) { + stopInteractionRef.current?.(); + pendingClickCleanupRef.current?.(); setPosition({ x: 0, y: 0 }); setSize({}); setIsDragging(false); @@ -69,9 +73,14 @@ const DraggableResizableModalFrame: React.FC } }, [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 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 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 event.preventDefault(); event.stopPropagation(); + pendingClickCleanupRef.current?.(); activeInteractionRef.current = 'resize'; setIsResizing(true); setSize({ @@ -151,7 +187,18 @@ const DraggableResizableModalFrame: React.FC 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 }); }; - 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; }) as typeof AntdModal.useModal; +export { DraggableResizableModalFrame }; + export default ResizableDraggableModal; diff --git a/frontend/src/components/resultDiff/ResultDiffPanel.tsx b/frontend/src/components/resultDiff/ResultDiffPanel.tsx index 0c2118a9..ceaf0365 100644 --- a/frontend/src/components/resultDiff/ResultDiffPanel.tsx +++ b/frontend/src/components/resultDiff/ResultDiffPanel.tsx @@ -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 = ({ originW: number; originH: number; } | null>(null); + const { startInteraction: startManagedInteraction } = useManagedPointerInteraction(open && detached); const persistBoundsTimerRef = useRef | null>(null); const persistBounds = useCallback((next: FloatingBounds) => { @@ -642,6 +644,51 @@ const ResultDiffPanel: React.FC = ({ 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 = ({ 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; diff --git a/frontend/src/components/useDataGridColumnResize.interaction.test.tsx b/frontend/src/components/useDataGridColumnResize.interaction.test.tsx new file mode 100644 index 00000000..14461c9d --- /dev/null +++ b/frontend/src/components/useDataGridColumnResize.interaction.test.tsx @@ -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>(); + + addEventListener(type: string, listener: Listener) { + const listeners = this.listeners.get(type) ?? new Set(); + 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 | null = null; + let fakeWindow: FakeEventTarget; + let fakeDocument: FakeEventTarget & { body: { style: { cursor: string; userSelect: string } } }; + let ghost: { style: { display: string; transform: string } }; + let scheduledFrames: Map; + let nextFrameId: number; + let setColumnWidths: ReturnType; + + 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) => Record); + 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(); + }); + (resize!.ghostRef as React.MutableRefObject).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(); + }); +}); diff --git a/frontend/src/components/useDataGridColumnResize.ts b/frontend/src/components/useDataGridColumnResize.ts index 5533afcb..fe9a3643 100644 --- a/frontend/src/components/useDataGridColumnResize.ts +++ b/frontend/src/components/useDataGridColumnResize.ts @@ -8,6 +8,11 @@ const ROW_NUMBER_MIN_WIDTH = 28; const ROW_NUMBER_MAX_WIDTH = 120; type UseDataGridColumnResizeContext = Record; +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(null); const latestClientXRef = useRef(null); const isResizingRef = useRef(false); + const resizeGateTimeoutRef = useRef | null>(null); + const resizeBodyStyleRef = useRef<{ cursor: string; userSelect: string } | null>(null); + const resizeListenersRef = useRef(null); + const setColumnWidthsRef = useRef(setColumnWidths); const autoFitCanvasRef = useRef(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) => ({ ...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) => ({ ...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, diff --git a/frontend/src/hooks/useAppLogPanelResize.test.tsx b/frontend/src/hooks/useAppLogPanelResize.test.tsx new file mode 100644 index 00000000..0b645595 --- /dev/null +++ b/frontend/src/hooks/useAppLogPanelResize.test.tsx @@ -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>(); + + addEventListener(type: string, listener: Listener) { + const listeners = this.listeners.get(type) ?? new Set(); + 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 | 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(); + }); + (resize!.logGhostRef as React.MutableRefObject).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); + }); +}); diff --git a/frontend/src/hooks/useAppLogPanelResize.ts b/frontend/src/hooks/useAppLogPanelResize.ts index 90fd2c8d..b56f1829 100644 --- a/frontend/src/hooks/useAppLogPanelResize.ts +++ b/frontend/src/hooks/useAppLogPanelResize.ts @@ -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(null); + const logResizeListenersRef = useRef(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, diff --git a/frontend/src/hooks/useAppSidebarResize.test.tsx b/frontend/src/hooks/useAppSidebarResize.test.tsx new file mode 100644 index 00000000..a8c8fbb0 --- /dev/null +++ b/frontend/src/hooks/useAppSidebarResize.test.tsx @@ -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>(); + + addEventListener(type: string, listener: Listener) { + const listeners = this.listeners.get(type) ?? new Set(); + 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 | 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; + let nextFrameId: number; + let setSidebarWidth: ReturnType; + + 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(); + }); + (resize!.siderRef as React.MutableRefObject).current = new FakeHTMLElement(); + (resize!.ghostRef as React.MutableRefObject).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(); + }); +}); diff --git a/frontend/src/hooks/useAppSidebarResize.ts b/frontend/src/hooks/useAppSidebarResize.ts index b86f412b..5f027ffe 100644 --- a/frontend/src/hooks/useAppSidebarResize.ts +++ b/frontend/src/hooks/useAppSidebarResize.ts @@ -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(null); const siderRef = useRef(null); const sidebarDragBodyStyleRef = useRef<{ cursor: string; userSelect: string; webkitUserSelect: string } | null>(null); + const sidebarResizeListenersRef = useRef(null); const latestMouseX = useRef(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, diff --git a/frontend/src/hooks/useManagedPointerInteraction.ts b/frontend/src/hooks/useManagedPointerInteraction.ts new file mode 100644 index 00000000..38f4b13b --- /dev/null +++ b/frontend/src/hooks/useManagedPointerInteraction.ts @@ -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 }; +};