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..fb3f56c4 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,215 +564,242 @@ describe('useResizer', () => { }); }); - describe('touch interactions', () => { - it('starts dragging on touchStart', () => { - const onResizeStart = vi.fn(); + describe('pointer capture', () => { + it('captures pointer on drag start', () => { + const mockElement = createMockElement(); 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); + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { + clientX: 300, + clientY: 0, + pointerId: 42, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); - expect(result.current.isDragging).toBe(true); - expect(onResizeStart).toHaveBeenCalledWith({ - sizes: [300, 700], - source: 'touch', - originalEvent: expect.any(TouchEvent), - }); + expect(mockElement.setPointerCapture).toHaveBeenCalledWith(42); }); - it('updates sizes during touch move', () => { + 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], + 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); - }); - - // Simulate touch move - act(() => { - const touchMoveEvent = new TouchEvent('touchmove', { - touches: [{ clientX: 350, clientY: 0 } as Touch], + const pointerDown = result.current.handlePointerDown(0); + const event = createMockReactPointerEvent(mockElement, { + clientX: 300, + clientY: 0, + pointerId: 1, + pointerType: 'touch', }); - document.dispatchEvent(touchMoveEvent); - vi.runAllTimers(); + pointerDown(event); }); - expect(result.current.currentSizes[0]).toBe(350); - expect(result.current.currentSizes[1]).toBe(650); + expect(result.current.isDragging).toBe(true); + expect(onResizeStart).toHaveBeenCalledWith({ + sizes: [300, 700], + source: 'pointer', + originalEvent: expect.any(PointerEvent), + }); }); - it('ends drag on touchEnd', () => { - const onResizeEnd = vi.fn(); + it('handles pen input via pointer events', () => { + const mockElement = createMockElement(); const { result } = renderHook(() => useResizer({ direction: 'horizontal', sizes: [300, 700], minSizes: [100, 100], maxSizes: [500, 900], - onResizeEnd, }) ); - // Start touch + // Pen generates pointer events with pointerType="pen" 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: 'pen', + }); + 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(result.current.isDragging).toBe(false); - expect(onResizeEnd).toHaveBeenCalled(); }); - it('handles touch with no touches gracefully', () => { + 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, }) ); - // Touch start with no touches + // Start drag with pointer ID 1 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, + }); + pointerDown(event); }); - // Should not start dragging - expect(result.current.isDragging).toBe(false); + 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(); }); - }); - describe('RAF throttling uses latest position', () => { - it('uses the latest mouse position when multiple moves occur before RAF fires', () => { - const onResize = vi.fn(); + 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], - onResize, }) ); - // Start drag 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: 42, + }); + pointerDown(event); }); - // Simulate multiple rapid mouse moves BEFORE RAF fires - // This simulates what happens when mouse events fire faster than 60fps + expect(mockElement.setPointerCapture).toHaveBeenCalledWith(42); + + // End drag act(() => { - // First move - schedules RAF - document.dispatchEvent( - new MouseEvent('mousemove', { clientX: 320, clientY: 0 }) - ); - // Second move - should be captured but RAF already pending document.dispatchEvent( - new MouseEvent('mousemove', { clientX: 350, clientY: 0 }) + createPointerEvent('pointerup', { pointerId: 42 }) ); - // Third move - the latest position - document.dispatchEvent( - new MouseEvent('mousemove', { clientX: 400, clientY: 0 }) - ); - - // Now RAF fires - should use position 400, not 320 - vi.runAllTimers(); }); - // Should use the LATEST position (400), not the first one (320) - expect(result.current.currentSizes[0]).toBe(400); - expect(result.current.currentSizes[1]).toBe(600); + expect(mockElement.releasePointerCapture).toHaveBeenCalledWith(42); + expect(result.current.isDragging).toBe(false); }); + }); - it('uses the latest touch position when multiple moves occur before RAF fires', () => { + describe('RAF throttling uses latest position', () => { + it('uses the latest pointer position when multiple moves occur before RAF fires', () => { + const mockElement = createMockElement(); + const onResize = vi.fn(); const { result } = renderHook(() => useResizer({ direction: 'horizontal', sizes: [300, 700], minSizes: [100, 100], maxSizes: [500, 900], + onResize, }) ); - // Start touch + // Start drag 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 = createPointerEvent('pointerdown', { + clientX: 300, + clientY: 0, + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); }); - // Simulate multiple rapid touch moves BEFORE RAF fires + // 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 TouchEvent('touchmove', { - touches: [{ clientX: 320, clientY: 0 } as Touch], + createPointerEvent('pointermove', { + clientX: 320, + clientY: 0, + pointerId: 1, }) ); + // Second move - should be captured but RAF already pending document.dispatchEvent( - new TouchEvent('touchmove', { - touches: [{ clientX: 350, clientY: 0 } as Touch], + createPointerEvent('pointermove', { + clientX: 350, + clientY: 0, + pointerId: 1, }) ); + // Third move - the latest position document.dispatchEvent( - new TouchEvent('touchmove', { - touches: [{ clientX: 400, clientY: 0 } as Touch], + createPointerEvent('pointermove', { + clientX: 400, + clientY: 0, + pointerId: 1, }) ); + // Now RAF fires - should use position 400, not 320 vi.runAllTimers(); }); - // Should use the LATEST position (400) + // Should use the LATEST position (400), not the first one (320) expect(result.current.currentSizes[0]).toBe(400); expect(result.current.currentSizes[1]).toBe(600); }); @@ -657,6 +807,7 @@ describe('useResizer', () => { 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 +821,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..f3895fba 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,23 @@ 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) => { + // 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,30 +176,20 @@ 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 handlePointerUp = useCallback((e: PointerEvent) => { + // Only handle events from the captured pointer + if ( + !dragStateRef.current || + e.pointerId !== dragStateRef.current.pointerId + ) { + return; + } - const handleMouseUp = useCallback(() => { - if (!dragStateRef.current) return; + // Release pointer capture + const { element, pointerId } = dragStateRef.current; + if (element?.hasPointerCapture?.(pointerId)) { + element.releasePointerCapture(pointerId); + } // Cancel any pending RAF if (rafRef.current) { @@ -208,50 +206,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 +236,7 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { if (onResizeStart) { onResizeStart({ sizes: currentSizes, - source: 'touch', + source: 'pointer', originalEvent: e.nativeEvent, }); } @@ -267,36 +244,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;