Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 129 additions & 1 deletion src/components/SplitPane.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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(
<SplitPane
direction="horizontal"
divider={CustomDivider}
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
>
<Pane defaultSize={300}>Pane 1</Pane>
<Pane>Pane 2</Pane>
</SplitPane>
);

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) => (
<div role="separator" onMouseDown={onMouseDown} />
);
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) => (
<div role="separator" onTouchStart={onTouchStart} />
);
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 `<div {...props}>` custom divider ends up with
const SpreadDivider = ({ onPointerDown, onTouchStart }: DividerProps) => (
<div
role="separator"
onPointerDown={onPointerDown}
onTouchStart={onTouchStart}
/>
);
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);
});
});
11 changes: 9 additions & 2 deletions src/components/SplitPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand All @@ -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);
},
Expand Down
52 changes: 52 additions & 0 deletions src/hooks/useResizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand Down
64 changes: 43 additions & 21 deletions src/hooks/useResizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<DragState | null>(null);

const rafRef = useRef<number | null>(null);
const mountedRef = useRef(true);
Expand Down Expand Up @@ -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;
}

Expand All @@ -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
Expand Down Expand Up @@ -225,21 +239,29 @@ 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';

dragStateRef.current = {
dividerIndex,
startPosition,
startSizes: currentSizes,
pointerId: e.pointerId,
pointerId,
pointerType,
element,
};
Expand Down
Loading