diff --git a/src/components/SplitPane.test.tsx b/src/components/SplitPane.test.tsx index 5f0958e7..0f48fcb0 100644 --- a/src/components/SplitPane.test.tsx +++ b/src/components/SplitPane.test.tsx @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, act } from '@testing-library/react'; +import { render, screen, act, fireEvent } from '@testing-library/react'; import { SplitPane } from './SplitPane'; import { Pane } from './Pane'; import { triggerResize, clearResizeObservers } from '../test/setup'; +import type { DividerProps } from '../types'; // These match the mock in test/setup.ts const CONTAINER_WIDTH = 1024; @@ -599,3 +600,130 @@ describe('SplitPane resize stability', () => { expect(newHeight1).toBeCloseTo(767.5, 1); // 50% of 1535 }); }); + +describe('SplitPane legacy divider handlers', () => { + beforeEach(() => { + clearResizeObservers(); + }); + + async function renderWithDivider( + CustomDivider: (props: DividerProps) => JSX.Element + ) { + const onResizeStart = vi.fn(); + const onResizeEnd = vi.fn(); + const { container } = render( + + Pane 1 + Pane 2 + + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + const divider = container.querySelector( + '[role="separator"]' + ) as HTMLElement; + // jsdom doesn't implement pointer capture + divider.setPointerCapture = vi.fn(); + divider.releasePointerCapture = vi.fn(); + divider.hasPointerCapture = vi.fn().mockReturnValue(true); + + const pane1 = container.querySelectorAll( + '[data-pane="true"]' + )[0] as HTMLElement; + + return { divider, pane1, onResizeStart, onResizeEnd }; + } + + async function dispatchPointer( + target: EventTarget, + type: string, + init: PointerEventInit + ) { + await act(async () => { + target.dispatchEvent(new PointerEvent(type, { bubbles: true, ...init })); + await vi.runAllTimersAsync(); + }); + } + + async function touchStart(divider: HTMLElement, clientX: number) { + await act(async () => { + fireEvent.touchStart(divider, { + touches: [{ identifier: 0, clientX, clientY: 0 }], + }); + }); + } + + // The drag must not assume which pointerId the browser gives the mouse + it.each([0, 1])( + 'drags with an onMouseDown divider (mouse pointerId %i)', + async (pointerId) => { + const MouseDivider = ({ onMouseDown }: DividerProps) => ( +
+ ); + const { divider, pane1, onResizeEnd } = + await renderWithDivider(MouseDivider); + + await act(async () => { + fireEvent.mouseDown(divider, { clientX: 300, clientY: 0 }); + }); + await dispatchPointer(document, 'pointermove', { + clientX: 400, + pointerId, + }); + expect(pane1).toHaveStyle({ width: '400px' }); + + await dispatchPointer(document, 'pointerup', { clientX: 400, pointerId }); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + } + ); + + it('drags with an onTouchStart divider', async () => { + const TouchDivider = ({ onTouchStart }: DividerProps) => ( +
+ ); + const { divider, pane1, onResizeEnd } = + await renderWithDivider(TouchDivider); + const touch = { pointerId: 7, pointerType: 'touch' }; + + await touchStart(divider, 300); + await dispatchPointer(document, 'pointermove', { ...touch, clientX: 400 }); + expect(pane1).toHaveStyle({ width: '400px' }); + + await dispatchPointer(document, 'pointerup', { ...touch, clientX: 400 }); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + }); + + it('ignores the touchstart that follows pointerdown on a divider wired to both', async () => { + // The handlers a `
` custom divider ends up with + const SpreadDivider = ({ onPointerDown, onTouchStart }: DividerProps) => ( +
+ ); + const { divider, pane1, onResizeStart, onResizeEnd } = + await renderWithDivider(SpreadDivider); + const touch = { pointerId: 7, pointerType: 'touch' }; + + // A finger fires pointerdown, then touchstart + await dispatchPointer(divider, 'pointerdown', { ...touch, clientX: 300 }); + await touchStart(divider, 300); + expect(onResizeStart).toHaveBeenCalledTimes(1); + expect(divider.setPointerCapture).toHaveBeenCalledWith(7); + + await dispatchPointer(document, 'pointermove', { ...touch, clientX: 400 }); + expect(pane1).toHaveStyle({ width: '400px' }); + + await dispatchPointer(document, 'pointerup', { ...touch, clientX: 400 }); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/SplitPane.tsx b/src/components/SplitPane.tsx index 6fc8f277..6b2ccd64 100644 --- a/src/components/SplitPane.tsx +++ b/src/components/SplitPane.tsx @@ -302,11 +302,15 @@ export function SplitPane(props: SplitPaneProps) { const createMouseDownHandler = useCallback( (index: number) => (e: React.MouseEvent) => { // Create a synthetic pointer event from the mouse event + // Spreading a SyntheticEvent only copies own properties, + // so preventDefault and stopPropagation must be re-bound. + // There is no pointerId: the resizer adopts the pointer that follows. const pointerEvent = { ...e, - pointerId: 1, pointerType: 'mouse', nativeEvent: e.nativeEvent, + preventDefault: e.preventDefault.bind(e), + stopPropagation: e.stopPropagation.bind(e), } as unknown as React.PointerEvent; handlePointerDown(index)(pointerEvent); }, @@ -318,13 +322,16 @@ export function SplitPane(props: SplitPaneProps) { const touch = e.touches[0]; if (!touch) return; // Create a synthetic pointer event from the touch event + // See createMouseDownHandler for why preventDefault/stopPropagation are + // re-bound, and why there is no pointerId (touch.identifier is not one). const pointerEvent = { ...e, clientX: touch.clientX, clientY: touch.clientY, - pointerId: touch.identifier, pointerType: 'touch', nativeEvent: e.nativeEvent, + preventDefault: e.preventDefault.bind(e), + stopPropagation: e.stopPropagation.bind(e), } as unknown as React.PointerEvent; handlePointerDown(index)(pointerEvent); }, diff --git a/src/hooks/useResizer.test.ts b/src/hooks/useResizer.test.ts index ee904c67..b6dee4c2 100644 --- a/src/hooks/useResizer.test.ts +++ b/src/hooks/useResizer.test.ts @@ -818,6 +818,58 @@ describe('useResizer', () => { expect(onResize).toHaveBeenCalled(); }); + it('adopts the first primary pointer when the drag starts without a pointerId', () => { + const mockElement = createMockElement(); + const { result } = renderHook(() => + useResizer({ + direction: 'horizontal', + sizes: [300, 700], + minSizes: [100, 100], + maxSizes: [900, 900], + }) + ); + + // As sent by SplitPane's legacy onTouchStart handler + act(() => { + const pointerDown = result.current.handlePointerDown(0); + pointerDown({ + preventDefault: vi.fn(), + clientX: 300, + clientY: 0, + pointerType: 'touch', + currentTarget: mockElement, + nativeEvent: new Event('touchstart'), + } as unknown as React.PointerEvent); + }); + + expect(result.current.isDragging).toBe(true); + expect(mockElement.setPointerCapture).not.toHaveBeenCalled(); + + const move = (clientX: number, pointerId: number, isPrimary: boolean) => + act(() => { + document.dispatchEvent( + new PointerEvent('pointermove', { + clientX, + pointerId, + pointerType: 'touch', + isPrimary, + bubbles: true, + }) + ); + vi.runAllTimers(); + }); + + // A second finger is not adopted + move(600, 8, false); + expect(result.current.currentSizes).toEqual([300, 700]); + + // The primary pointer is, and the drag then follows only that pointer + move(350, 7, true); + expect(result.current.currentSizes).toEqual([350, 650]); + move(600, 9, true); + expect(result.current.currentSizes).toEqual([350, 650]); + }); + it('releases pointer capture on drag end', () => { const mockElement = createMockElement(); const { result } = renderHook(() => diff --git a/src/hooks/useResizer.ts b/src/hooks/useResizer.ts index 0ff220a4..7d352adf 100644 --- a/src/hooks/useResizer.ts +++ b/src/hooks/useResizer.ts @@ -31,6 +31,33 @@ export interface UseResizerResult { handlePointerDown: (dividerIndex: number) => (e: React.PointerEvent) => void; } +interface DragState { + dividerIndex: number; + startPosition: number; + startSizes: number[]; + /** null until known, for drags started by the legacy mouse/touch handlers */ + pointerId: number | null; + pointerType: 'mouse' | 'touch' | 'pen'; + element: HTMLElement | null; +} + +/** + * Check whether a document pointer event belongs to the drag in progress. + * A drag started by the legacy mouse/touch handlers has no pointerId, so it + * adopts the first primary pointer that follows, whatever its type (browsers + * also fire compatibility mouse events for pen input). + */ +function isDragPointer( + dragState: DragState | null, + e: PointerEvent +): dragState is DragState { + if (!dragState) return false; + if (dragState.pointerId === null && e.isPrimary) { + dragState.pointerId = e.pointerId; + } + return e.pointerId === dragState.pointerId; +} + /** * Hook that handles pointer-based resizing of panes. * @@ -64,14 +91,7 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { const [isDragging, setIsDragging] = useState(false); const [currentSizes, setCurrentSizes] = useState(sizes); - const dragStateRef = useRef<{ - dividerIndex: number; - startPosition: number; - startSizes: number[]; - pointerId: number; - pointerType: 'mouse' | 'touch' | 'pen'; - element: HTMLElement | null; - } | null>(null); + const dragStateRef = useRef(null); const rafRef = useRef(null); const mountedRef = useRef(true); @@ -160,10 +180,7 @@ 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 - ) { + if (!isDragPointer(dragStateRef.current, e)) { return; } @@ -187,17 +204,14 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { const handlePointerUp = useCallback((e: PointerEvent) => { // Only handle events from the captured pointer - if ( - !dragStateRef.current || - e.pointerId !== dragStateRef.current.pointerId - ) { + if (!isDragPointer(dragStateRef.current, e)) { return; } // Release pointer capture - const { element, pointerId, pointerType } = dragStateRef.current; - if (element?.hasPointerCapture?.(pointerId)) { - element.releasePointerCapture(pointerId); + const { element, pointerType } = dragStateRef.current; + if (element?.hasPointerCapture?.(e.pointerId)) { + element.releasePointerCapture(e.pointerId); } // Cancel any pending RAF @@ -225,13 +239,21 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { const handlePointerDown = useCallback( (dividerIndex: number) => (e: React.PointerEvent) => { + // A divider wired to both onPointerDown and a legacy handler gets two + // starts per gesture (e.g. pointerdown then touchstart), so keep the first + if (dragStateRef.current) return; + e.preventDefault(); const startPosition = direction === 'horizontal' ? e.clientX : e.clientY; const element = e.currentTarget as HTMLElement; + // Events from the legacy mouse/touch handlers have no pointerId + const pointerId = e.pointerId ?? null; // Capture the pointer to receive all pointer events even if pointer leaves element - element.setPointerCapture(e.pointerId); + if (pointerId !== null) { + element.setPointerCapture(pointerId); + } const pointerType = e.pointerType as 'mouse' | 'touch' | 'pen'; @@ -239,7 +261,7 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { dividerIndex, startPosition, startSizes: currentSizes, - pointerId: e.pointerId, + pointerId, pointerType, element, };