From a67f4bce8963c51935f4796654831793962b64a2 Mon Sep 17 00:00:00 2001 From: tomkp Date: Wed, 18 Feb 2026 09:52:11 +0000 Subject: [PATCH 1/2] feat: replace mouse/touch events with pointer events - Use unified Pointer Events API for all drag interactions (mouse, touch, pen) - Implement pointer capture for better drag UX (prevents cursor blinking) - Simplify useResizer hook by removing separate mouse/touch handlers - Update DividerProps to use onPointerDown instead of onMouseDown/onTouchStart/onTouchEnd - Update ResizeEvent source type from 'mouse' | 'touch' to 'pointer' - Add PointerEvent polyfill for jsdom in test setup Closes #878 --- src/components/Divider.tsx | 8 +- src/components/SplitPane.tsx | 12 +- src/hooks/useResizer.test.ts | 499 ++++++++++++++++++++--------------- src/hooks/useResizer.ts | 111 ++------ src/test/setup.ts | 39 +++ src/types/index.ts | 14 +- 6 files changed, 356 insertions(+), 327 deletions(-) diff --git a/src/components/Divider.tsx b/src/components/Divider.tsx index 2bc35cbd..aee5d99e 100644 --- a/src/components/Divider.tsx +++ b/src/components/Divider.tsx @@ -42,9 +42,7 @@ export function Divider(props: DividerProps) { index, isDragging, disabled, - onMouseDown, - onTouchStart, - onTouchEnd, + onPointerDown, onKeyDown, className, style, @@ -106,9 +104,7 @@ export function Divider(props: DividerProps) { tabIndex={disabled ? -1 : 0} className={combinedClassName} style={combinedStyle} - onMouseDown={disabled ? undefined : onMouseDown} - onTouchStart={disabled ? undefined : onTouchStart} - onTouchEnd={disabled ? undefined : onTouchEnd} + onPointerDown={disabled ? undefined : onPointerDown} onKeyDown={disabled ? undefined : onKeyDown} data-divider-index={index} > diff --git a/src/components/SplitPane.tsx b/src/components/SplitPane.tsx index 56bafcb4..d70e7519 100644 --- a/src/components/SplitPane.tsx +++ b/src/components/SplitPane.tsx @@ -273,13 +273,7 @@ export function SplitPane(props: SplitPaneProps) { ); // Resizer hook - const { - isDragging, - currentSizes, - handleMouseDown, - handleTouchStart, - handleTouchEnd, - } = useResizer({ + const { isDragging, currentSizes, handlePointerDown } = useResizer({ direction, sizes: paneSizes, minSizes, @@ -354,9 +348,7 @@ export function SplitPane(props: SplitPaneProps) { index={index} isDragging={isDragging} disabled={!resizable} - onMouseDown={handleMouseDown(index)} - onTouchStart={handleTouchStart(index)} - onTouchEnd={handleTouchEnd} + onPointerDown={handlePointerDown(index)} onKeyDown={handleKeyDown(index)} className={dividerClassName} style={dividerStyle} diff --git a/src/hooks/useResizer.test.ts b/src/hooks/useResizer.test.ts index 6579cf2b..4138a713 100644 --- a/src/hooks/useResizer.test.ts +++ b/src/hooks/useResizer.test.ts @@ -2,6 +2,57 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useResizer } from './useResizer'; +// Helper to create a mock element with pointer capture methods +function createMockElement(): HTMLDivElement { + const element = document.createElement('div'); + element.setPointerCapture = vi.fn(); + element.releasePointerCapture = vi.fn(); + element.hasPointerCapture = vi.fn().mockReturnValue(true); + return element; +} + +// Helper to create a PointerEvent with required properties +function createPointerEvent( + type: string, + options: { clientX?: number; clientY?: number; pointerId?: number } = {} +): PointerEvent { + return new PointerEvent(type, { + clientX: options.clientX ?? 0, + clientY: options.clientY ?? 0, + pointerId: options.pointerId ?? 1, + bubbles: true, + }); +} + +// Helper to create a mock React PointerEvent with nativeEvent +function createMockReactPointerEvent( + mockElement: HTMLDivElement, + options: { + clientX?: number; + clientY?: number; + pointerId?: number; + pointerType?: string; + } = {} +): React.PointerEvent { + const nativeEvent = new PointerEvent('pointerdown', { + clientX: options.clientX ?? 0, + clientY: options.clientY ?? 0, + pointerId: options.pointerId ?? 1, + pointerType: options.pointerType ?? 'mouse', + bubbles: true, + }); + + return { + preventDefault: vi.fn(), + clientX: options.clientX ?? 0, + clientY: options.clientY ?? 0, + pointerId: options.pointerId ?? 1, + pointerType: options.pointerType ?? 'mouse', + currentTarget: mockElement, + nativeEvent, + } as unknown as React.PointerEvent; +} + describe('useResizer', () => { beforeEach(() => { vi.useFakeTimers(); @@ -46,6 +97,7 @@ describe('useResizer', () => { }); it('does not update sizes while dragging', () => { + const mockElement = createMockElement(); const { result, rerender } = renderHook( ({ sizes }) => useResizer({ @@ -57,15 +109,16 @@ describe('useResizer', () => { { initialProps: { sizes: [300, 700] } } ); - // Start a drag + // Start a drag using pointer events act(() => { - const mouseDown = result.current.handleMouseDown(0); - mouseDown({ - preventDefault: vi.fn(), + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { clientX: 300, clientY: 0, - nativeEvent: new MouseEvent('mousedown'), - } as unknown as React.MouseEvent); + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); expect(result.current.isDragging).toBe(true); @@ -78,8 +131,9 @@ describe('useResizer', () => { }); }); - describe('mouse drag interactions', () => { - it('starts dragging on mouseDown', () => { + describe('pointer drag interactions', () => { + it('starts dragging on pointerDown and captures pointer', () => { + const mockElement = createMockElement(); const onResizeStart = vi.fn(); const { result } = renderHook(() => useResizer({ @@ -92,24 +146,26 @@ describe('useResizer', () => { ); act(() => { - const mouseDown = result.current.handleMouseDown(0); - mouseDown({ - preventDefault: vi.fn(), + const pointerDown = result.current.handlePointerDown(0); + const event = createMockReactPointerEvent(mockElement, { clientX: 300, clientY: 0, - nativeEvent: new MouseEvent('mousedown'), - } as unknown as React.MouseEvent); + pointerId: 1, + }); + pointerDown(event); }); expect(result.current.isDragging).toBe(true); + expect(mockElement.setPointerCapture).toHaveBeenCalledWith(1); expect(onResizeStart).toHaveBeenCalledWith({ sizes: [300, 700], - source: 'mouse', - originalEvent: expect.any(MouseEvent), + source: 'pointer', + originalEvent: expect.any(PointerEvent), }); }); it('updates sizes during horizontal drag', () => { + const mockElement = createMockElement(); const onResize = vi.fn(); const { result } = renderHook(() => useResizer({ @@ -123,22 +179,24 @@ describe('useResizer', () => { // Start drag act(() => { - const mouseDown = result.current.handleMouseDown(0); - mouseDown({ - preventDefault: vi.fn(), + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { clientX: 300, clientY: 0, - nativeEvent: new MouseEvent('mousedown'), - } as unknown as React.MouseEvent); + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); - // Simulate mouse move (document level event) + // Simulate pointer move (document level event) act(() => { - const mouseMoveEvent = new MouseEvent('mousemove', { + const pointerMoveEvent = createPointerEvent('pointermove', { clientX: 350, clientY: 0, + pointerId: 1, }); - document.dispatchEvent(mouseMoveEvent); + document.dispatchEvent(pointerMoveEvent); vi.runAllTimers(); // RAF is mocked with timers }); @@ -148,6 +206,7 @@ describe('useResizer', () => { }); it('updates sizes during vertical drag', () => { + const mockElement = createMockElement(); const onResize = vi.fn(); const { result } = renderHook(() => useResizer({ @@ -161,22 +220,24 @@ describe('useResizer', () => { // Start drag act(() => { - const mouseDown = result.current.handleMouseDown(0); - mouseDown({ - preventDefault: vi.fn(), + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { clientX: 0, clientY: 300, - nativeEvent: new MouseEvent('mousedown'), - } as unknown as React.MouseEvent); + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); - // Simulate vertical mouse move + // Simulate vertical pointer move act(() => { - const mouseMoveEvent = new MouseEvent('mousemove', { + const pointerMoveEvent = createPointerEvent('pointermove', { clientX: 0, clientY: 350, + pointerId: 1, }); - document.dispatchEvent(mouseMoveEvent); + document.dispatchEvent(pointerMoveEvent); vi.runAllTimers(); }); @@ -184,7 +245,8 @@ describe('useResizer', () => { expect(result.current.currentSizes[1]).toBe(450); }); - it('ends drag on mouseUp', () => { + it('ends drag on pointerUp and releases pointer capture', () => { + const mockElement = createMockElement(); const onResizeEnd = vi.fn(); const { result } = renderHook(() => useResizer({ @@ -198,30 +260,74 @@ describe('useResizer', () => { // Start drag act(() => { - const mouseDown = result.current.handleMouseDown(0); - mouseDown({ - preventDefault: vi.fn(), + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { clientX: 300, clientY: 0, - nativeEvent: new MouseEvent('mousedown'), - } as unknown as React.MouseEvent); + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); expect(result.current.isDragging).toBe(true); // End drag act(() => { - document.dispatchEvent(new MouseEvent('mouseup')); + const pointerUpEvent = createPointerEvent('pointerup', { + pointerId: 1, + }); + document.dispatchEvent(pointerUpEvent); }); expect(result.current.isDragging).toBe(false); expect(onResizeEnd).toHaveBeenCalledWith([300, 700], { sizes: [300, 700], - source: 'mouse', + source: 'pointer', + }); + }); + + it('ends drag on pointerCancel and releases pointer capture', () => { + const mockElement = createMockElement(); + const onResizeEnd = vi.fn(); + const { result } = renderHook(() => + useResizer({ + direction: 'horizontal', + sizes: [300, 700], + minSizes: [100, 100], + maxSizes: [500, 900], + onResizeEnd, + }) + ); + + // Start drag + act(() => { + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { + clientX: 300, + clientY: 0, + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); + }); + + expect(result.current.isDragging).toBe(true); + + // Cancel drag + act(() => { + const pointerCancelEvent = createPointerEvent('pointercancel', { + pointerId: 1, + }); + document.dispatchEvent(pointerCancelEvent); }); + + expect(result.current.isDragging).toBe(false); + expect(onResizeEnd).toHaveBeenCalled(); }); it('respects minimum size constraints', () => { + const mockElement = createMockElement(); const { result } = renderHook(() => useResizer({ direction: 'horizontal', @@ -233,22 +339,24 @@ describe('useResizer', () => { // Start drag act(() => { - const mouseDown = result.current.handleMouseDown(0); - mouseDown({ - preventDefault: vi.fn(), + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { clientX: 300, clientY: 0, - nativeEvent: new MouseEvent('mousedown'), - } as unknown as React.MouseEvent); + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); // Try to drag past minimum act(() => { - const mouseMoveEvent = new MouseEvent('mousemove', { + const pointerMoveEvent = createPointerEvent('pointermove', { clientX: 50, // Would make first pane 50px clientY: 0, + pointerId: 1, }); - document.dispatchEvent(mouseMoveEvent); + document.dispatchEvent(pointerMoveEvent); vi.runAllTimers(); }); @@ -257,6 +365,7 @@ describe('useResizer', () => { }); it('respects maximum size constraints', () => { + const mockElement = createMockElement(); const { result } = renderHook(() => useResizer({ direction: 'horizontal', @@ -268,22 +377,24 @@ describe('useResizer', () => { // Start drag act(() => { - const mouseDown = result.current.handleMouseDown(0); - mouseDown({ - preventDefault: vi.fn(), + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { clientX: 300, clientY: 0, - nativeEvent: new MouseEvent('mousedown'), - } as unknown as React.MouseEvent); + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); // Try to drag past maximum act(() => { - const mouseMoveEvent = new MouseEvent('mousemove', { + const pointerMoveEvent = createPointerEvent('pointermove', { clientX: 600, // Would make first pane 600px clientY: 0, + pointerId: 1, }); - document.dispatchEvent(mouseMoveEvent); + document.dispatchEvent(pointerMoveEvent); vi.runAllTimers(); }); @@ -294,6 +405,7 @@ describe('useResizer', () => { describe('snap points', () => { it('snaps to nearby snap points', () => { + const mockElement = createMockElement(); const { result } = renderHook(() => useResizer({ direction: 'horizontal', @@ -307,22 +419,24 @@ describe('useResizer', () => { // Start drag act(() => { - const mouseDown = result.current.handleMouseDown(0); - mouseDown({ - preventDefault: vi.fn(), + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { clientX: 300, clientY: 0, - nativeEvent: new MouseEvent('mousedown'), - } as unknown as React.MouseEvent); + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); // Drag to position near snap point (395 should snap to 400) act(() => { - const mouseMoveEvent = new MouseEvent('mousemove', { + const pointerMoveEvent = createPointerEvent('pointermove', { clientX: 395, clientY: 0, + pointerId: 1, }); - document.dispatchEvent(mouseMoveEvent); + document.dispatchEvent(pointerMoveEvent); vi.runAllTimers(); }); @@ -330,6 +444,7 @@ describe('useResizer', () => { }); it('does not snap when outside tolerance', () => { + const mockElement = createMockElement(); const { result } = renderHook(() => useResizer({ direction: 'horizontal', @@ -343,22 +458,24 @@ describe('useResizer', () => { // Start drag act(() => { - const mouseDown = result.current.handleMouseDown(0); - mouseDown({ - preventDefault: vi.fn(), + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { clientX: 300, clientY: 0, - nativeEvent: new MouseEvent('mousedown'), - } as unknown as React.MouseEvent); + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); // Drag to position outside snap tolerance (350 is 50 away from 400) act(() => { - const mouseMoveEvent = new MouseEvent('mousemove', { + const pointerMoveEvent = createPointerEvent('pointermove', { clientX: 350, clientY: 0, + pointerId: 1, }); - document.dispatchEvent(mouseMoveEvent); + document.dispatchEvent(pointerMoveEvent); vi.runAllTimers(); }); @@ -368,6 +485,7 @@ describe('useResizer', () => { describe('step-based resizing', () => { it('applies step to drag movements', () => { + const mockElement = createMockElement(); const { result } = renderHook(() => useResizer({ direction: 'horizontal', @@ -380,22 +498,24 @@ describe('useResizer', () => { // Start drag act(() => { - const mouseDown = result.current.handleMouseDown(0); - mouseDown({ - preventDefault: vi.fn(), + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { clientX: 300, clientY: 0, - nativeEvent: new MouseEvent('mousedown'), - } as unknown as React.MouseEvent); + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); // Drag 73 pixels (should be rounded to 50) act(() => { - const mouseMoveEvent = new MouseEvent('mousemove', { + const pointerMoveEvent = createPointerEvent('pointermove', { clientX: 373, clientY: 0, + pointerId: 1, }); - document.dispatchEvent(mouseMoveEvent); + document.dispatchEvent(pointerMoveEvent); vi.runAllTimers(); }); @@ -405,6 +525,7 @@ describe('useResizer', () => { describe('multiple panes', () => { it('handles drag on middle divider (3 panes)', () => { + const mockElement = createMockElement(); const { result } = renderHook(() => useResizer({ direction: 'horizontal', @@ -416,22 +537,24 @@ describe('useResizer', () => { // Drag middle divider (index 1) act(() => { - const mouseDown = result.current.handleMouseDown(1); - mouseDown({ - preventDefault: vi.fn(), + const pointerDown = result.current.handlePointerDown(1); + const event = createPointerEvent('pointerdown', { clientX: 700, // Start at divider 1 position (300 + 400) clientY: 0, - nativeEvent: new MouseEvent('mousedown'), - } as unknown as React.MouseEvent); + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); // Drag right by 50px act(() => { - const mouseMoveEvent = new MouseEvent('mousemove', { + const pointerMoveEvent = createPointerEvent('pointermove', { clientX: 750, clientY: 0, + pointerId: 1, }); - document.dispatchEvent(mouseMoveEvent); + document.dispatchEvent(pointerMoveEvent); vi.runAllTimers(); }); @@ -441,36 +564,9 @@ describe('useResizer', () => { }); }); - describe('touch interactions', () => { - it('starts dragging on touchStart', () => { - const onResizeStart = vi.fn(); - const { result } = renderHook(() => - useResizer({ - direction: 'horizontal', - sizes: [300, 700], - minSizes: [100, 100], - maxSizes: [500, 900], - onResizeStart, - }) - ); - - act(() => { - const touchStart = result.current.handleTouchStart(0); - touchStart({ - touches: [{ clientX: 300, clientY: 0 }], - nativeEvent: new TouchEvent('touchstart'), - } as unknown as React.TouchEvent); - }); - - expect(result.current.isDragging).toBe(true); - expect(onResizeStart).toHaveBeenCalledWith({ - sizes: [300, 700], - source: 'touch', - originalEvent: expect.any(TouchEvent), - }); - }); - - it('updates sizes during touch move', () => { + describe('pointer capture', () => { + it('captures pointer on drag start', () => { + const mockElement = createMockElement(); const { result } = renderHook(() => useResizer({ direction: 'horizontal', @@ -480,63 +576,55 @@ describe('useResizer', () => { }) ); - // Start touch act(() => { - const touchStart = result.current.handleTouchStart(0); - touchStart({ - touches: [{ clientX: 300, clientY: 0 }], - nativeEvent: new TouchEvent('touchstart'), - } as unknown as React.TouchEvent); - }); - - // Simulate touch move - act(() => { - const touchMoveEvent = new TouchEvent('touchmove', { - touches: [{ clientX: 350, clientY: 0 } as Touch], + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { + clientX: 300, + clientY: 0, + pointerId: 42, }); - document.dispatchEvent(touchMoveEvent); - vi.runAllTimers(); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); - expect(result.current.currentSizes[0]).toBe(350); - expect(result.current.currentSizes[1]).toBe(650); + expect(mockElement.setPointerCapture).toHaveBeenCalledWith(42); }); - it('ends drag on touchEnd', () => { - const onResizeEnd = vi.fn(); + it('handles touch input via pointer events', () => { + const mockElement = createMockElement(); + const onResizeStart = vi.fn(); const { result } = renderHook(() => useResizer({ direction: 'horizontal', sizes: [300, 700], minSizes: [100, 100], maxSizes: [500, 900], - onResizeEnd, + onResizeStart, }) ); - // Start touch + // Touch generates pointer events with pointerType="touch" act(() => { - const touchStart = result.current.handleTouchStart(0); - touchStart({ - touches: [{ clientX: 300, clientY: 0 }], - nativeEvent: new TouchEvent('touchstart'), - } as unknown as React.TouchEvent); + const pointerDown = result.current.handlePointerDown(0); + const event = createMockReactPointerEvent(mockElement, { + clientX: 300, + clientY: 0, + pointerId: 1, + pointerType: 'touch', + }); + pointerDown(event); }); expect(result.current.isDragging).toBe(true); - - // End touch via handleTouchEnd - act(() => { - result.current.handleTouchEnd({ - preventDefault: vi.fn(), - } as unknown as React.TouchEvent); + expect(onResizeStart).toHaveBeenCalledWith({ + sizes: [300, 700], + source: 'pointer', + originalEvent: expect.any(PointerEvent), }); - - expect(result.current.isDragging).toBe(false); - expect(onResizeEnd).toHaveBeenCalled(); }); - it('handles touch with no touches gracefully', () => { + it('handles pen input via pointer events', () => { + const mockElement = createMockElement(); const { result } = renderHook(() => useResizer({ direction: 'horizontal', @@ -546,22 +634,25 @@ describe('useResizer', () => { }) ); - // Touch start with no touches + // Pen generates pointer events with pointerType="pen" act(() => { - const touchStart = result.current.handleTouchStart(0); - touchStart({ - touches: [], - nativeEvent: new TouchEvent('touchstart'), - } as unknown as React.TouchEvent); + const pointerDown = result.current.handlePointerDown(0); + const event = createMockReactPointerEvent(mockElement, { + clientX: 300, + clientY: 0, + pointerId: 1, + pointerType: 'pen', + }); + pointerDown(event); }); - // Should not start dragging - expect(result.current.isDragging).toBe(false); + expect(result.current.isDragging).toBe(true); }); }); describe('RAF throttling uses latest position', () => { - it('uses the latest mouse position when multiple moves occur before RAF fires', () => { + it('uses the latest pointer position when multiple moves occur before RAF fires', () => { + const mockElement = createMockElement(); const onResize = vi.fn(); const { result } = renderHook(() => useResizer({ @@ -575,29 +666,42 @@ describe('useResizer', () => { // Start drag act(() => { - const mouseDown = result.current.handleMouseDown(0); - mouseDown({ - preventDefault: vi.fn(), + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { clientX: 300, clientY: 0, - nativeEvent: new MouseEvent('mousedown'), - } as unknown as React.MouseEvent); + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); - // Simulate multiple rapid mouse moves BEFORE RAF fires - // This simulates what happens when mouse events fire faster than 60fps + // Simulate multiple rapid pointer moves BEFORE RAF fires + // This simulates what happens when pointer events fire faster than 60fps act(() => { // First move - schedules RAF document.dispatchEvent( - new MouseEvent('mousemove', { clientX: 320, clientY: 0 }) + createPointerEvent('pointermove', { + clientX: 320, + clientY: 0, + pointerId: 1, + }) ); // Second move - should be captured but RAF already pending document.dispatchEvent( - new MouseEvent('mousemove', { clientX: 350, clientY: 0 }) + createPointerEvent('pointermove', { + clientX: 350, + clientY: 0, + pointerId: 1, + }) ); // Third move - the latest position document.dispatchEvent( - new MouseEvent('mousemove', { clientX: 400, clientY: 0 }) + createPointerEvent('pointermove', { + clientX: 400, + clientY: 0, + pointerId: 1, + }) ); // Now RAF fires - should use position 400, not 320 @@ -608,55 +712,11 @@ describe('useResizer', () => { expect(result.current.currentSizes[0]).toBe(400); expect(result.current.currentSizes[1]).toBe(600); }); - - it('uses the latest touch position when multiple moves occur before RAF fires', () => { - const { result } = renderHook(() => - useResizer({ - direction: 'horizontal', - sizes: [300, 700], - minSizes: [100, 100], - maxSizes: [500, 900], - }) - ); - - // Start touch - act(() => { - const touchStart = result.current.handleTouchStart(0); - touchStart({ - touches: [{ clientX: 300, clientY: 0 }], - nativeEvent: new TouchEvent('touchstart'), - } as unknown as React.TouchEvent); - }); - - // Simulate multiple rapid touch moves BEFORE RAF fires - act(() => { - document.dispatchEvent( - new TouchEvent('touchmove', { - touches: [{ clientX: 320, clientY: 0 } as Touch], - }) - ); - document.dispatchEvent( - new TouchEvent('touchmove', { - touches: [{ clientX: 350, clientY: 0 } as Touch], - }) - ); - document.dispatchEvent( - new TouchEvent('touchmove', { - touches: [{ clientX: 400, clientY: 0 } as Touch], - }) - ); - - vi.runAllTimers(); - }); - - // Should use the LATEST position (400) - expect(result.current.currentSizes[0]).toBe(400); - expect(result.current.currentSizes[1]).toBe(600); - }); }); describe('event cleanup', () => { it('cleans up event listeners when drag ends', () => { + const mockElement = createMockElement(); const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); const { result } = renderHook(() => @@ -670,26 +730,29 @@ describe('useResizer', () => { // Start drag act(() => { - const mouseDown = result.current.handleMouseDown(0); - mouseDown({ - preventDefault: vi.fn(), + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { clientX: 300, clientY: 0, - nativeEvent: new MouseEvent('mousedown'), - } as unknown as React.MouseEvent); + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); // End drag act(() => { - document.dispatchEvent(new MouseEvent('mouseup')); + document.dispatchEvent( + createPointerEvent('pointerup', { pointerId: 1 }) + ); }); expect(removeEventListenerSpy).toHaveBeenCalledWith( - 'mousemove', + 'pointermove', expect.any(Function) ); expect(removeEventListenerSpy).toHaveBeenCalledWith( - 'mouseup', + 'pointerup', expect.any(Function) ); diff --git a/src/hooks/useResizer.ts b/src/hooks/useResizer.ts index e11b49e5..0078bc42 100644 --- a/src/hooks/useResizer.ts +++ b/src/hooks/useResizer.ts @@ -28,20 +28,18 @@ export interface UseResizerOptions { export interface UseResizerResult { isDragging: boolean; currentSizes: number[]; - handleMouseDown: (dividerIndex: number) => (e: React.MouseEvent) => void; - handleTouchStart: (dividerIndex: number) => (e: React.TouchEvent) => void; - handleTouchEnd: (e: React.TouchEvent) => void; + handlePointerDown: (dividerIndex: number) => (e: React.PointerEvent) => void; } /** - * Hook that handles mouse and touch-based resizing of panes. + * Hook that handles pointer-based resizing of panes. * * This is a low-level hook used internally by SplitPane. For most use cases, * you should use the SplitPane component directly. * * Features: - * - Mouse drag support - * - Touch support for mobile + * - Unified pointer events (handles mouse, touch, and pen input) + * - Pointer capture for reliable drag behavior * - RAF-throttled updates for smooth performance * - Snap points support * - Step-based resizing @@ -70,6 +68,8 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { dividerIndex: number; startPosition: number; startSizes: number[]; + pointerId: number; + element: HTMLElement | null; } | null>(null); const rafRef = useRef(null); @@ -141,15 +141,15 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { if (onResize) { onResize(newSizes, { sizes: newSizes, - source: 'mouse', + source: 'pointer', }); } }, [direction, step, minSizes, maxSizes, snapPoints, snapTolerance, onResize] ); - const handleMouseMove = useCallback( - (e: MouseEvent) => { + const handlePointerMove = useCallback( + (e: PointerEvent) => { e.preventDefault(); // Always store the latest position to avoid stale closure in RAF callback @@ -168,29 +168,7 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { [handleDrag] ); - const handleTouchMove = useCallback( - (e: TouchEvent) => { - e.preventDefault(); - - const touch = e.touches[0]; - if (!touch) return; - - // Always store the latest position to avoid stale closure in RAF callback - lastPositionRef.current = { x: touch.clientX, y: touch.clientY }; - - if (rafRef.current) return; - - rafRef.current = requestAnimationFrame(() => { - rafRef.current = null; - if (mountedRef.current && lastPositionRef.current) { - handleDrag(lastPositionRef.current.x, lastPositionRef.current.y); - } - }); - }, - [handleDrag] - ); - - const handleMouseUp = useCallback(() => { + const handlePointerUp = useCallback(() => { if (!dragStateRef.current) return; // Cancel any pending RAF @@ -208,50 +186,29 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { if (latestOnResizeEnd) { latestOnResizeEnd(latestSizes, { sizes: latestSizes, - source: 'mouse', + source: 'pointer', }); } dragStateRef.current = null; }, []); - const handleMouseDown = useCallback( - (dividerIndex: number) => (e: React.MouseEvent) => { + const handlePointerDown = useCallback( + (dividerIndex: number) => (e: React.PointerEvent) => { e.preventDefault(); const startPosition = direction === 'horizontal' ? e.clientX : e.clientY; + const element = e.currentTarget as HTMLElement; - dragStateRef.current = { - dividerIndex, - startPosition, - startSizes: currentSizes, - }; - - setIsDragging(true); - - if (onResizeStart) { - onResizeStart({ - sizes: currentSizes, - source: 'mouse', - originalEvent: e.nativeEvent, - }); - } - }, - [direction, currentSizes, onResizeStart] - ); - - const handleTouchStart = useCallback( - (dividerIndex: number) => (e: React.TouchEvent) => { - const touch = e.touches[0]; - if (!touch) return; - - const startPosition = - direction === 'horizontal' ? touch.clientX : touch.clientY; + // Capture the pointer to receive all pointer events even if pointer leaves element + element.setPointerCapture(e.pointerId); dragStateRef.current = { dividerIndex, startPosition, startSizes: currentSizes, + pointerId: e.pointerId, + element, }; setIsDragging(true); @@ -259,7 +216,7 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { if (onResizeStart) { onResizeStart({ sizes: currentSizes, - source: 'touch', + source: 'pointer', originalEvent: e.nativeEvent, }); } @@ -267,36 +224,24 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { [direction, currentSizes, onResizeStart] ); - const handleTouchEnd = useCallback( - (e: React.TouchEvent) => { - e.preventDefault(); - handleMouseUp(); - }, - [handleMouseUp] - ); - - // Set up global event listeners + // Set up global event listeners for pointer events useEffect(() => { if (!isDragging) return; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - document.addEventListener('touchmove', handleTouchMove, { passive: false }); - document.addEventListener('touchend', handleMouseUp); + document.addEventListener('pointermove', handlePointerMove); + document.addEventListener('pointerup', handlePointerUp); + document.addEventListener('pointercancel', handlePointerUp); return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - document.removeEventListener('touchmove', handleTouchMove); - document.removeEventListener('touchend', handleMouseUp); + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + document.removeEventListener('pointercancel', handlePointerUp); }; - }, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove]); + }, [isDragging, handlePointerMove, handlePointerUp]); return { isDragging, currentSizes, - handleMouseDown, - handleTouchStart, - handleTouchEnd, + handlePointerDown, }; } diff --git a/src/test/setup.ts b/src/test/setup.ts index 86f1d8eb..bdc57552 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -4,6 +4,45 @@ import { vi } from 'vitest'; // Use fake timers globally vi.useFakeTimers(); +// Polyfill PointerEvent for jsdom (which doesn't include it) +class MockPointerEvent extends MouseEvent { + readonly pointerId: number; + readonly pointerType: string; + readonly width: number; + readonly height: number; + readonly pressure: number; + readonly tangentialPressure: number; + readonly tiltX: number; + readonly tiltY: number; + readonly twist: number; + readonly isPrimary: boolean; + + constructor(type: string, init?: PointerEventInit) { + super(type, init); + this.pointerId = init?.pointerId ?? 0; + this.pointerType = init?.pointerType ?? 'mouse'; + this.width = init?.width ?? 1; + this.height = init?.height ?? 1; + this.pressure = init?.pressure ?? 0; + this.tangentialPressure = init?.tangentialPressure ?? 0; + this.tiltX = init?.tiltX ?? 0; + this.tiltY = init?.tiltY ?? 0; + this.twist = init?.twist ?? 0; + this.isPrimary = init?.isPrimary ?? true; + } + + getCoalescedEvents(): PointerEvent[] { + return []; + } + + getPredictedEvents(): PointerEvent[] { + return []; + } +} + +(globalThis as unknown as { PointerEvent: typeof PointerEvent }).PointerEvent = + MockPointerEvent as unknown as typeof PointerEvent; + // Mock getBoundingClientRect to return proper dimensions Element.prototype.getBoundingClientRect = vi.fn(() => ({ width: 1024, diff --git a/src/types/index.ts b/src/types/index.ts index 768e4ca7..4a16ad9d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,8 +6,8 @@ export type Size = string | number; export interface ResizeEvent { sizes: number[]; - source: 'mouse' | 'touch' | 'keyboard'; - originalEvent?: MouseEvent | TouchEvent | KeyboardEvent; + source: 'pointer' | 'keyboard'; + originalEvent?: PointerEvent | KeyboardEvent; } export interface SplitPaneProps { @@ -93,14 +93,8 @@ export interface DividerProps { /** Whether divider can be interacted with */ disabled: boolean; - /** Mouse down handler */ - onMouseDown: (e: React.MouseEvent) => void; - - /** Touch start handler */ - onTouchStart: (e: React.TouchEvent) => void; - - /** Touch end handler */ - onTouchEnd: (e: React.TouchEvent) => void; + /** Pointer down handler (handles mouse, touch, and pen input) */ + onPointerDown: (e: React.PointerEvent) => void; /** Keyboard handler */ onKeyDown: (e: React.KeyboardEvent) => void; From d30b5df9cbcf6da5a6010203f3dfd3303acda0b3 Mon Sep 17 00:00:00 2001 From: tomkp Date: Wed, 18 Feb 2026 09:56:48 +0000 Subject: [PATCH 2/2] fix: add pointer ID filtering and pointer capture release - Filter pointer events by pointerId to handle multi-touch correctly - Explicitly release pointer capture on drag end - Add tests for multi-touch scenarios and pointer capture release --- src/hooks/useResizer.test.ts | 91 ++++++++++++++++++++++++++++++++++++ src/hooks/useResizer.ts | 24 +++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/hooks/useResizer.test.ts b/src/hooks/useResizer.test.ts index 4138a713..fb3f56c4 100644 --- a/src/hooks/useResizer.test.ts +++ b/src/hooks/useResizer.test.ts @@ -648,6 +648,97 @@ describe('useResizer', () => { expect(result.current.isDragging).toBe(true); }); + + it('ignores pointer events from non-captured pointers (multi-touch)', () => { + const mockElement = createMockElement(); + const onResize = vi.fn(); + const { result } = renderHook(() => + useResizer({ + direction: 'horizontal', + sizes: [300, 700], + minSizes: [100, 100], + maxSizes: [500, 900], + onResize, + }) + ); + + // Start drag with pointer ID 1 + act(() => { + const pointerDown = result.current.handlePointerDown(0); + const event = createMockReactPointerEvent(mockElement, { + clientX: 300, + clientY: 0, + pointerId: 1, + }); + pointerDown(event); + }); + + expect(result.current.isDragging).toBe(true); + + // Simulate a second touch with different pointer ID (should be ignored) + act(() => { + const pointerMoveEvent = createPointerEvent('pointermove', { + clientX: 500, // Would be a big move if processed + clientY: 0, + pointerId: 2, // Different pointer ID + }); + document.dispatchEvent(pointerMoveEvent); + vi.runAllTimers(); + }); + + // Sizes should NOT change because pointer ID 2 is not captured + expect(result.current.currentSizes).toEqual([300, 700]); + expect(onResize).not.toHaveBeenCalled(); + + // Now move with the correct pointer ID + act(() => { + const pointerMoveEvent = createPointerEvent('pointermove', { + clientX: 350, + clientY: 0, + pointerId: 1, // Correct pointer ID + }); + document.dispatchEvent(pointerMoveEvent); + vi.runAllTimers(); + }); + + // Sizes should change now + expect(result.current.currentSizes[0]).toBe(350); + expect(onResize).toHaveBeenCalled(); + }); + + it('releases pointer capture on drag end', () => { + const mockElement = createMockElement(); + const { result } = renderHook(() => + useResizer({ + direction: 'horizontal', + sizes: [300, 700], + minSizes: [100, 100], + maxSizes: [500, 900], + }) + ); + + act(() => { + const pointerDown = result.current.handlePointerDown(0); + const event = createMockReactPointerEvent(mockElement, { + clientX: 300, + clientY: 0, + pointerId: 42, + }); + pointerDown(event); + }); + + expect(mockElement.setPointerCapture).toHaveBeenCalledWith(42); + + // End drag + act(() => { + document.dispatchEvent( + createPointerEvent('pointerup', { pointerId: 42 }) + ); + }); + + expect(mockElement.releasePointerCapture).toHaveBeenCalledWith(42); + expect(result.current.isDragging).toBe(false); + }); }); describe('RAF throttling uses latest position', () => { diff --git a/src/hooks/useResizer.ts b/src/hooks/useResizer.ts index 0078bc42..f3895fba 100644 --- a/src/hooks/useResizer.ts +++ b/src/hooks/useResizer.ts @@ -150,6 +150,14 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { const handlePointerMove = useCallback( (e: PointerEvent) => { + // Only handle events from the captured pointer + if ( + !dragStateRef.current || + e.pointerId !== dragStateRef.current.pointerId + ) { + return; + } + e.preventDefault(); // Always store the latest position to avoid stale closure in RAF callback @@ -168,8 +176,20 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { [handleDrag] ); - const handlePointerUp = useCallback(() => { - if (!dragStateRef.current) return; + const handlePointerUp = useCallback((e: PointerEvent) => { + // Only handle events from the captured pointer + if ( + !dragStateRef.current || + e.pointerId !== dragStateRef.current.pointerId + ) { + return; + } + + // Release pointer capture + const { element, pointerId } = dragStateRef.current; + if (element?.hasPointerCapture?.(pointerId)) { + element.releasePointerCapture(pointerId); + } // Cancel any pending RAF if (rafRef.current) {