fix(panes): stop a stray touch hijacking a mouse divider drag (#11013)

Each pointer type has its own primary pointer, so a finger on a touchscreen
arrives with isPrimary true while a mouse drag is in flight and satisfies the
WSLg fallback in isActiveDragPointer. Exclude touch from that fallback; the
pen path WSLg needs is unchanged and a touch-started drag still matches by
pointerId.
This commit is contained in:
Brennan Benson
2026-07-27 17:13:00 -07:00
committed by GitHub
parent 6943638053
commit a72068015f
2 changed files with 213 additions and 2 deletions
@@ -220,9 +220,12 @@ export function attachDividerDrag(
// Why: WSLg's RDP input path reports press/release as a `mouse` pointer but
// streams motion as a `pen` pointer with a different pointerId, so a strict
// pointerId match drops every move. Any primary pointer continues the drag.
// pointerId match drops every move. Any primary pointer continues the drag,
// except touch: each pointer type has its own primary, so a stray finger
// would otherwise hijack an in-flight mouse/pen drag. A touch-started drag
// still matches by pointerId.
const isActiveDragPointer = (e: PointerEvent): boolean =>
e.pointerId === activePointerId || e.isPrimary
e.pointerId === activePointerId || (e.isPrimary && e.pointerType !== 'touch')
const onPointerMove = (e: PointerEvent): void => {
if (!dragging || !isActiveDragPointer(e) || !prevEl || !nextEl) {
@@ -0,0 +1,208 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDivider } from './pane-divider'
type PaneElement = HTMLElement & { style: Record<string, string> }
type DividerDragHarness = {
divider: HTMLElement
dividerListeners: Map<string, EventListener>
windowListeners: Map<string, EventListener>
previousPane: PaneElement
nextPane: PaneElement
onLayoutChanged: ReturnType<typeof vi.fn>
flushAnimationFrames: () => void
}
function createPaneElement(width: number): PaneElement {
return {
style: {},
classList: { contains: vi.fn(() => false) },
dispatchEvent: vi.fn(() => true),
getBoundingClientRect: vi.fn(() => ({
left: 0,
top: 0,
right: width,
bottom: 200,
width,
height: 200
})),
querySelectorAll: vi.fn(() => [])
} as unknown as PaneElement
}
function createPointerEvent(args: Partial<PointerEvent>): PointerEvent {
return {
preventDefault: vi.fn(),
pointerId: 1,
pointerType: 'mouse',
isPrimary: true,
clientX: 0,
clientY: 0,
...args
} as unknown as PointerEvent
}
function createDividerDragHarness(): DividerDragHarness {
const dividerListeners = new Map<string, EventListener>()
const windowListeners = new Map<string, EventListener>()
const capturedPointerIds = new Set<number>()
const animationFrames = new Map<number, FrameRequestCallback>()
const previousPane = createPaneElement(100)
const nextPane = createPaneElement(300)
const divider = {
style: { setProperty: vi.fn() },
classList: { add: vi.fn(), remove: vi.fn() },
addEventListener: vi.fn((event: string, listener: EventListener) => {
dividerListeners.set(event, listener)
}),
removeEventListener: vi.fn((event: string, listener: EventListener) => {
if (dividerListeners.get(event) === listener) {
dividerListeners.delete(event)
}
}),
setPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.add(pointerId)),
hasPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.has(pointerId)),
releasePointerCapture: vi.fn((pointerId: number) => capturedPointerIds.delete(pointerId)),
previousElementSibling: previousPane,
nextElementSibling: nextPane
} as unknown as HTMLElement
vi.stubGlobal('document', { createElement: vi.fn(() => divider) })
vi.stubGlobal('window', {
addEventListener: vi.fn((event: string, listener: EventListener) => {
windowListeners.set(event, listener)
}),
removeEventListener: vi.fn((event: string, listener: EventListener) => {
if (windowListeners.get(event) === listener) {
windowListeners.delete(event)
}
})
})
let nextFrameId = 0
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
nextFrameId += 1
animationFrames.set(nextFrameId, callback)
return nextFrameId
})
)
vi.stubGlobal(
'cancelAnimationFrame',
vi.fn((frameId: number) => animationFrames.delete(frameId))
)
const onLayoutChanged = vi.fn()
createDivider(true, {}, { refitPanesUnder: vi.fn(), onLayoutChanged })
return {
divider,
dividerListeners,
windowListeners,
previousPane,
nextPane,
onLayoutChanged,
flushAnimationFrames: () => {
for (const [frameId, callback] of animationFrames) {
animationFrames.delete(frameId)
callback(16)
}
}
}
}
// Each pointer type has its own primary pointer, so a finger on a touchscreen
// arrives with isPrimary true while a mouse drag is already in flight.
const STRAY_TOUCH = { pointerId: 42, pointerType: 'touch', isPrimary: true } as const
function startMouseDrag(harness: DividerDragHarness): void {
harness.dividerListeners.get('pointerdown')?.(
createPointerEvent({ pointerId: 9, pointerType: 'mouse', isPrimary: true, clientX: 100 })
)
harness.windowListeners.get('pointermove')?.(
createPointerEvent({ pointerId: 9, pointerType: 'mouse', isPrimary: true, clientX: 180 })
)
harness.flushAnimationFrames()
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('divider drag pointer-type isolation', () => {
it('ignores stray touch motion during an active mouse drag', () => {
const harness = createDividerDragHarness()
startMouseDrag(harness)
expect(harness.previousPane.style.flex).toBe('180 1 0%')
harness.windowListeners.get('pointermove')?.(
createPointerEvent({ ...STRAY_TOUCH, clientX: 320 })
)
harness.flushAnimationFrames()
expect(harness.previousPane.style.flex).toBe('180 1 0%')
expect(harness.nextPane.style.flex).toBe('220 1 0%')
})
it('does not commit the layout when a stray touch lifts mid mouse drag', () => {
const harness = createDividerDragHarness()
startMouseDrag(harness)
harness.windowListeners.get('pointerup')?.(createPointerEvent({ ...STRAY_TOUCH, clientX: 320 }))
expect(harness.onLayoutChanged).not.toHaveBeenCalled()
expect(harness.divider.classList.remove).not.toHaveBeenCalledWith('is-dragging')
expect(harness.windowListeners.has('pointermove')).toBe(true)
harness.windowListeners.get('pointerup')?.(
createPointerEvent({ pointerId: 9, pointerType: 'mouse', isPrimary: true, clientX: 180 })
)
expect(harness.previousPane.style.flex).toBe('180 1 0%')
expect(harness.onLayoutChanged).toHaveBeenCalledTimes(1)
})
it('does not revert the layout when a stray touch is cancelled mid mouse drag', () => {
const harness = createDividerDragHarness()
harness.previousPane.style.flex = '2 1 0%'
harness.nextPane.style.flex = '3 1 0%'
startMouseDrag(harness)
harness.windowListeners.get('pointercancel')?.(
createPointerEvent({ ...STRAY_TOUCH, clientX: 320 })
)
expect(harness.previousPane.style.flex).toBe('180 1 0%')
expect(harness.windowListeners.has('pointermove')).toBe(true)
})
it('still continues a mouse drag from a primary pen pointer (WSLg relay)', () => {
const harness = createDividerDragHarness()
harness.dividerListeners.get('pointerdown')?.(
createPointerEvent({ pointerId: 1, pointerType: 'mouse', isPrimary: true, clientX: 100 })
)
harness.windowListeners.get('pointermove')?.(
createPointerEvent({ pointerId: 19, pointerType: 'pen', isPrimary: true, clientX: 180 })
)
harness.windowListeners.get('pointerup')?.(
createPointerEvent({ pointerId: 1, pointerType: 'mouse', isPrimary: true, clientX: 180 })
)
expect(harness.previousPane.style.flex).toBe('180 1 0%')
expect(harness.nextPane.style.flex).toBe('220 1 0%')
expect(harness.onLayoutChanged).toHaveBeenCalledTimes(1)
})
it('drives a touch-started drag from its own pointerId', () => {
const harness = createDividerDragHarness()
const touchDrag = { pointerId: 7, pointerType: 'touch', isPrimary: true } as const
harness.dividerListeners.get('pointerdown')?.(
createPointerEvent({ ...touchDrag, clientX: 100 })
)
harness.windowListeners.get('pointermove')?.(createPointerEvent({ ...touchDrag, clientX: 180 }))
harness.flushAnimationFrames()
harness.windowListeners.get('pointerup')?.(createPointerEvent({ ...touchDrag, clientX: 180 }))
expect(harness.previousPane.style.flex).toBe('180 1 0%')
expect(harness.nextPane.style.flex).toBe('220 1 0%')
expect(harness.onLayoutChanged).toHaveBeenCalledTimes(1)
})
})