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) => (
+
` 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,
};