From a72068015f75446e299251ca3f7d0d4e2ba4fc40 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:13:00 -0700 Subject: [PATCH] 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. --- .../src/lib/pane-manager/pane-divider-drag.ts | 7 +- .../pane-divider-stray-touch.test.ts | 208 ++++++++++++++++++ 2 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts diff --git a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts index a01a49de988..073cc29e874 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts @@ -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) { diff --git a/src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts b/src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts new file mode 100644 index 00000000000..0a5d0c967e0 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDivider } from './pane-divider' + +type PaneElement = HTMLElement & { style: Record } + +type DividerDragHarness = { + divider: HTMLElement + dividerListeners: Map + windowListeners: Map + previousPane: PaneElement + nextPane: PaneElement + onLayoutChanged: ReturnType + 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 { + 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() + const windowListeners = new Map() + const capturedPointerIds = new Set() + const animationFrames = new Map() + 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) + }) +})