diff --git a/src/lib/litegraph/src/CanvasPointer.ts b/src/lib/litegraph/src/CanvasPointer.ts index 380e50f41b..b4175e9531 100644 --- a/src/lib/litegraph/src/CanvasPointer.ts +++ b/src/lib/litegraph/src/CanvasPointer.ts @@ -54,7 +54,10 @@ export class CanvasPointer { * After a flick gesture is complete, the automatic wheel events are sent with * reduced frequency, but much higher deltaX and deltaY values. */ - static trackpadMaxGap = 200 + static trackpadMaxGap = 500 + + /** The maximum time in milliseconds to buffer a high-res wheel event. */ + static maxHighResBufferTime = 10 /** The element this PointerState should capture input against when dragging. */ element: Element @@ -90,8 +93,23 @@ export class CanvasPointer { /** The last pointerup event for the primary button */ eUp?: CanvasPointerEvent - /** The last pointermove event that was treated as a trackpad gesture. */ - lastTrackpadEvent?: WheelEvent + /** Currently detected input device type */ + detectedDevice: 'mouse' | 'trackpad' = 'mouse' + + /** Timestamp of last wheel event for cooldown tracking */ + lastWheelEventTime: number = 0 + + /** Flag to track if we've received the first wheel event */ + hasReceivedWheelEvent: boolean = false + + /** Buffered Linux wheel event awaiting confirmation */ + bufferedLinuxEvent?: WheelEvent + + /** Timestamp when Linux event was buffered */ + bufferedLinuxEventTime: number = 0 + + /** Timer ID for Linux buffer clearing */ + linuxBufferTimeoutId?: ReturnType /** * If set, as soon as the mouse moves outside the click drift threshold, this action is run once. @@ -274,32 +292,178 @@ export class CanvasPointer { } /** - * Checks if the given wheel event is part of a continued trackpad gesture. + * Checks if the given wheel event is part of a trackpad gesture. + * This method now uses the new device detection internally for improved accuracy. * @param e The wheel event to check - * @returns `true` if the event is part of a continued trackpad gesture, otherwise `false` + * @returns `true` if the event is part of a trackpad gesture, otherwise `false` + */ + isTrackpadGesture(e: WheelEvent): boolean { + // Use the new device detection + const now = performance.now() + const timeSinceLastEvent = Math.max(0, now - this.lastWheelEventTime) + this.lastWheelEventTime = now + + if (this.#isHighResWheelEvent(e, now)) { + this.detectedDevice = 'mouse' + } else if (this.#isWithinCooldown(timeSinceLastEvent)) { + if (this.#shouldBufferLinuxEvent(e)) { + this.#bufferLinuxEvent(e, now) + } + } else { + this.#updateDeviceMode(e, now) + this.hasReceivedWheelEvent = true + } + + return this.detectedDevice === 'trackpad' + } + + /** + * Validates buffered high res wheel events and switches to mouse mode if pattern matches. + * @returns `true` if switched to mouse mode + */ + #isHighResWheelEvent(event: WheelEvent, now: number): boolean { + if (!this.bufferedLinuxEvent || this.bufferedLinuxEventTime <= 0) { + return false + } + + const timeSinceBuffer = now - this.bufferedLinuxEventTime + + if (timeSinceBuffer > CanvasPointer.maxHighResBufferTime) { + this.#clearLinuxBuffer() + return false + } + + if ( + event.deltaX === 0 && + this.#isLinuxWheelPattern(this.bufferedLinuxEvent.deltaY, event.deltaY) + ) { + this.#clearLinuxBuffer() + return true + } + + return false + } + + /** + * Checks if we're within the cooldown period where mode switching is disabled. + */ + #isWithinCooldown(timeSinceLastEvent: number): boolean { + const isFirstEvent = !this.hasReceivedWheelEvent + const cooldownExpired = timeSinceLastEvent >= CanvasPointer.trackpadMaxGap + return !isFirstEvent && !cooldownExpired + } + + /** + * Updates the device mode based on event patterns. + */ + #updateDeviceMode(event: WheelEvent, now: number): void { + if (this.#isTrackpadPattern(event)) { + this.detectedDevice = 'trackpad' + } else if (this.#isMousePattern(event)) { + this.detectedDevice = 'mouse' + } else if ( + this.detectedDevice === 'trackpad' && + this.#shouldBufferLinuxEvent(event) + ) { + this.#bufferLinuxEvent(event, now) + } + } + + /** + * Clears the buffered Linux wheel event and associated timer. */ - #isContinuationOfGesture(e: WheelEvent): boolean { - const { lastTrackpadEvent } = this - if (!lastTrackpadEvent) return false + #clearLinuxBuffer(): void { + this.bufferedLinuxEvent = undefined + this.bufferedLinuxEventTime = 0 + if (this.linuxBufferTimeoutId !== undefined) { + clearTimeout(this.linuxBufferTimeoutId) + this.linuxBufferTimeoutId = undefined + } + } + + /** + * Checks if the event matches trackpad input patterns. + * @param event The wheel event to check + */ + #isTrackpadPattern(event: WheelEvent): boolean { + // Two-finger panning: non-zero deltaX AND deltaY + if (event.deltaX !== 0 && event.deltaY !== 0) return true + + // Pinch-to-zoom: ctrlKey with small deltaY + if (event.ctrlKey && Math.abs(event.deltaY) < 10) return true + return false + } + + /** + * Checks if the event matches mouse wheel input patterns. + * @param event The wheel event to check + */ + #isMousePattern(event: WheelEvent): boolean { + const absoluteDeltaY = Math.abs(event.deltaY) + + // Primary threshold for switching from trackpad to mouse + if (absoluteDeltaY > 80) return true + + // Secondary threshold when already in mouse mode return ( - e.timeStamp - lastTrackpadEvent.timeStamp < CanvasPointer.trackpadMaxGap + absoluteDeltaY >= 60 && + event.deltaX === 0 && + this.detectedDevice === 'mouse' ) } /** - * Checks if the given wheel event is part of a trackpad gesture. - * @param e The wheel event to check - * @returns `true` if the event is part of a trackpad gesture, otherwise `false` + * Checks if the event should be buffered as a potential Linux wheel event. + * @param event The wheel event to check */ - isTrackpadGesture(e: WheelEvent): boolean { - if (this.#isContinuationOfGesture(e)) { - this.lastTrackpadEvent = e - return true + #shouldBufferLinuxEvent(event: WheelEvent): boolean { + const absoluteDeltaY = Math.abs(event.deltaY) + const isInLinuxRange = absoluteDeltaY >= 10 && absoluteDeltaY < 60 + const isVerticalOnly = event.deltaX === 0 + const hasIntegerDelta = Number.isInteger(event.deltaY) + + return ( + this.detectedDevice === 'trackpad' && + isInLinuxRange && + isVerticalOnly && + hasIntegerDelta + ) + } + + /** + * Buffers a potential Linux wheel event for later confirmation. + * @param event The event to buffer + * @param now The current timestamp + */ + #bufferLinuxEvent(event: WheelEvent, now: number): void { + if (this.linuxBufferTimeoutId !== undefined) { + clearTimeout(this.linuxBufferTimeoutId) } - const threshold = CanvasPointer.trackpadThreshold - return Math.abs(e.deltaX) < threshold && Math.abs(e.deltaY) < threshold + this.bufferedLinuxEvent = event + this.bufferedLinuxEventTime = now + + // Set timeout to clear buffer after 10ms + this.linuxBufferTimeoutId = setTimeout(() => { + this.#clearLinuxBuffer() + }, CanvasPointer.maxHighResBufferTime) + } + + /** + * Checks if two deltaY values follow a Linux wheel pattern (divisibility). + * @param deltaY1 The first deltaY value + * @param deltaY2 The second deltaY value + */ + #isLinuxWheelPattern(deltaY1: number, deltaY2: number): boolean { + const absolute1 = Math.abs(deltaY1) + const absolute2 = Math.abs(deltaY2) + + if (absolute1 === 0 || absolute2 === 0) return false + if (absolute1 === absolute2) return true + + // Check if one value is a multiple of the other + return absolute1 % absolute2 === 0 || absolute2 % absolute1 === 0 } /** diff --git a/src/lib/litegraph/test/CanvasPointer.deviceDetection.test.ts b/src/lib/litegraph/test/CanvasPointer.deviceDetection.test.ts new file mode 100644 index 0000000000..6c4c56c085 --- /dev/null +++ b/src/lib/litegraph/test/CanvasPointer.deviceDetection.test.ts @@ -0,0 +1,1220 @@ +/** + * Test-Driven Design (TDD) tests for device detection functionality. + * + * These tests describe the expected behavior for device detection between + * mouse and trackpad inputs using an efficient timestamp-based approach. + * + * Design Philosophy: + * - Uses timestamps (performance.now()) instead of creating timers for every event + * - Creates at most ONE timer (for Linux buffer timeout), not one per wheel event + * - Handles potentially thousands of wheel events per second efficiently + * + * Expected new properties on CanvasPointer: + * - detectedDevice: 'mouse' | 'trackpad' + * - lastWheelEventTime: number // timestamp, not the event itself + * - bufferedLinuxEvent: WheelEvent | undefined + * - bufferedLinuxEventTime: number + * - linuxBufferTimeoutId: number | undefined // single timer handle + * + * Expected new methods on CanvasPointer: + * - detectDevice(event: WheelEvent): void + * - clearLinuxBuffer(): void + * + * Performance: This design can handle 10,000+ events without creating any timers + * (except one for Linux detection), ensuring smooth scrolling performance. + * + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { CanvasPointer } from '../src/CanvasPointer' + +describe('CanvasPointer Device Detection - Efficient Timestamp-Based TDD Tests', () => { + let element: HTMLDivElement + let pointer: CanvasPointer + + beforeEach(() => { + element = document.createElement('div') + pointer = new CanvasPointer(element) + // Mock performance.now() for timestamp-based testing + vi.spyOn(performance, 'now').mockReturnValue(0) + vi.spyOn(global, 'setTimeout') + vi.spyOn(global, 'clearTimeout') + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.clearAllTimers() + }) + + describe('Initial State', () => { + it('should start in mouse detected mode immediately after loading', () => { + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should have no last wheel event time immediately after loading', () => { + expect(pointer.lastWheelEventTime).toBe(0) + expect(pointer.hasReceivedWheelEvent).toBe(false) + }) + + it('should have no buffered Linux event immediately after loading', () => { + expect(pointer.bufferedLinuxEvent).toBeUndefined() + expect(pointer.bufferedLinuxEventTime).toBe(0) + expect(pointer.linuxBufferTimeoutId).toBeUndefined() + }) + }) + + describe('First Event Detection', () => { + describe('switching to trackpad on first event', () => { + it('should switch to trackpad if first event is pinch-to-zoom with deltaY < 10', () => { + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 9.5, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('trackpad') + expect(pointer.lastWheelEventTime).toBe(0) // Records current time + }) + + it('should switch to trackpad if first event is pinch-to-zoom with deltaY = 9.999', () => { + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 9.999, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('trackpad') + }) + + it('should NOT switch to trackpad if first event is pinch-to-zoom with deltaY = 10', () => { + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 10, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should switch to trackpad if first event is two-finger panning with integer values', () => { + const event = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: 5, + deltaX: -3 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('trackpad') + }) + + it('should switch to trackpad if first event is two-finger panning with ctrlKey true', () => { + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 7, + deltaX: 4 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('trackpad') + }) + + it('should switch to trackpad if first event is negative pinch-to-zoom with deltaY > -10', () => { + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: -9.5, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('trackpad') + }) + }) + + describe('remaining in mouse mode on first event', () => { + it('should remain in mouse mode if first event is pinch-to-zoom with deltaY >= 10', () => { + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 10.1, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should remain in mouse mode if first event is mouse wheel with deltaY = 120', () => { + const event = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: 120, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should remain in mouse mode if first event has only deltaY (no deltaX)', () => { + const event = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: 30, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('mouse') + }) + }) + }) + + describe('Mode Switching from Mouse to Trackpad', () => { + beforeEach(() => { + // Ensure we start in mouse mode + pointer.detectedDevice = 'mouse' + // Simulate a previous event to establish timing + pointer.lastWheelEventTime = 0 + pointer.hasReceivedWheelEvent = true + }) + + it('should switch to trackpad on two-finger panning with non-zero deltaX and deltaY', () => { + // Simulate 500ms has passed since last event + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: 15, + deltaX: 8 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('trackpad') + }) + + it('should NOT switch to trackpad on two-finger panning with zero deltaX', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: 15, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should NOT switch to trackpad on two-finger panning with zero deltaY', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: 0, + deltaX: 15 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should switch to trackpad on pinch-to-zoom with deltaY < 10', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 9.99, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('trackpad') + }) + + it('should switch to trackpad on pinch-to-zoom with deltaY = -5.5', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: -5.5, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('trackpad') + }) + + it('should NOT switch to trackpad on pinch-to-zoom with deltaY = 10', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 10, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should NOT switch to trackpad on pinch-to-zoom with deltaY = -10', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: -10, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('mouse') + }) + }) + + describe('Mode Switching from Trackpad to Mouse', () => { + beforeEach(() => { + // Set to trackpad mode + pointer.detectedDevice = 'trackpad' + pointer.lastWheelEventTime = 0 + pointer.hasReceivedWheelEvent = true + }) + + it('should switch to mouse on clear mouse wheel event with deltaY > 80', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: 80.1, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should switch to mouse on clear mouse wheel event with deltaY = 120', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: 120, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should switch to mouse on clear mouse wheel event with negative deltaY < -80', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: -90, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should NOT switch to mouse with deltaY = 80', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: 80, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('trackpad') + }) + + it('should NOT switch to mouse with deltaY = -80', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: -80, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('trackpad') + }) + + it('should NOT switch to mouse with deltaY = 79.999', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: 79.999, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('trackpad') + }) + }) + + describe('500ms Cooldown Period', () => { + it('should NOT allow switching from mouse to trackpad within 500ms', () => { + pointer.detectedDevice = 'mouse' + + // First event at time 0 + vi.spyOn(performance, 'now').mockReturnValue(0) + const event1 = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: 60, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + expect(pointer.lastWheelEventTime).toBe(0) + + // Try to switch after 499ms - should fail + vi.spyOn(performance, 'now').mockReturnValue(499) + const event2 = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 5, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should allow switching from mouse to trackpad after 500ms', () => { + pointer.detectedDevice = 'mouse' + + // First event at time 0 + vi.spyOn(performance, 'now').mockReturnValue(0) + const event1 = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: 60, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + + // Try to switch after 500ms - should succeed + vi.spyOn(performance, 'now').mockReturnValue(500) + const event2 = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 5, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + expect(pointer.detectedDevice).toBe('trackpad') + }) + + it('should NOT allow switching from trackpad to mouse within 500ms', () => { + pointer.detectedDevice = 'trackpad' + + // First event at time 0 + vi.spyOn(performance, 'now').mockReturnValue(0) + const event1 = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 5, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + + // Try to switch after 400ms - should fail + vi.spyOn(performance, 'now').mockReturnValue(400) + const event2 = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: 120, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + expect(pointer.detectedDevice).toBe('trackpad') + }) + + it('should maintain cooldown even with multiple events', () => { + pointer.detectedDevice = 'mouse' + + // Series of events that would normally trigger trackpad + const trackpadEvents = [ + { deltaY: 5, deltaX: 3 }, + { deltaY: -7, deltaX: 2 }, + { deltaY: 8, deltaX: -4 } + ] + + // Send first mouse event at time 0 + vi.spyOn(performance, 'now').mockReturnValue(0) + pointer.isTrackpadGesture( + new WheelEvent('wheel', { deltaY: 60, deltaX: 0 }) + ) + + // Send trackpad events within 500ms window + trackpadEvents.forEach((eventData, index) => { + vi.spyOn(performance, 'now').mockReturnValue((index + 1) * 100) // 100ms, 200ms, 300ms + const event = new WheelEvent('wheel', eventData) + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('mouse') // Should remain mouse + }) + + // After 500ms from last event (300ms + 500ms = 800ms), should be able to switch + vi.spyOn(performance, 'now').mockReturnValue(800) + const switchEvent = new WheelEvent('wheel', { deltaY: 5, deltaX: 3 }) + pointer.isTrackpadGesture(switchEvent) + expect(pointer.detectedDevice).toBe('trackpad') + }) + }) + + describe('Linux Wheel Event Buffering', () => { + beforeEach(() => { + pointer.detectedDevice = 'trackpad' + pointer.lastWheelEventTime = 0 + pointer.hasReceivedWheelEvent = true + vi.clearAllMocks() + }) + + it('should buffer possible Linux wheel event and create single timeout', () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + vi.spyOn(performance, 'now').mockReturnValue(500) // Allow mode switching + + const event = new WheelEvent('wheel', { + deltaY: 10, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.bufferedLinuxEvent).toBe(event) + expect(pointer.bufferedLinuxEventTime).toBe(500) + expect(pointer.detectedDevice).toBe('trackpad') // No immediate switch + + // Should create exactly ONE timeout for buffer clearing + expect(setTimeoutSpy).toHaveBeenCalledTimes(1) + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10) + }) + + it('should reuse timer when buffering new Linux event', () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout') + + // First Linux event + vi.spyOn(performance, 'now').mockReturnValue(500) + const event1 = new WheelEvent('wheel', { deltaY: 15, deltaX: 0 }) + pointer.isTrackpadGesture(event1) + const firstTimeoutId = pointer.linuxBufferTimeoutId + + // Second Linux event before timeout + vi.spyOn(performance, 'now').mockReturnValue(505) + const event2 = new WheelEvent('wheel', { deltaY: 10, deltaX: 0 }) + pointer.isTrackpadGesture(event2) + + // Should clear the first timeout and create a new one + expect(clearTimeoutSpy).toHaveBeenCalledWith(firstTimeoutId) + expect(setTimeoutSpy).toHaveBeenCalledTimes(2) + expect(pointer.bufferedLinuxEvent).toBe(event2) + }) + + it('should buffer negative Linux wheel values', () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + deltaY: -10, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.bufferedLinuxEvent).toBe(event) + expect(pointer.detectedDevice).toBe('trackpad') + expect(setTimeoutSpy).toHaveBeenCalledTimes(1) + }) + + it('should NOT buffer event with deltaY < 10', () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + deltaY: 9, + deltaX: 0 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.bufferedLinuxEvent).toBeUndefined() + expect(pointer.detectedDevice).toBe('trackpad') + expect(setTimeoutSpy).not.toHaveBeenCalled() // No timer created + }) + + it('should NOT buffer event with non-zero deltaX', () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + vi.spyOn(performance, 'now').mockReturnValue(500) + + const event = new WheelEvent('wheel', { + deltaY: 10, + deltaX: 1 + }) + + pointer.isTrackpadGesture(event) + expect(pointer.bufferedLinuxEvent).toBeUndefined() + expect(pointer.detectedDevice).toBe('trackpad') + expect(setTimeoutSpy).not.toHaveBeenCalled() // No timer created + }) + + it('should switch to mouse if follow-up event has same deltaY within 10ms', () => { + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout') + + // First event - buffered at time 500 + vi.spyOn(performance, 'now').mockReturnValue(500) + const event1 = new WheelEvent('wheel', { + deltaY: 10, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + expect(pointer.bufferedLinuxEvent).toBe(event1) + const timeoutId = pointer.linuxBufferTimeoutId + + // Follow-up within 10ms with same deltaY + vi.spyOn(performance, 'now').mockReturnValue(509) + const event2 = new WheelEvent('wheel', { + deltaY: 10, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + + expect(pointer.detectedDevice).toBe('mouse') + expect(pointer.bufferedLinuxEvent).toBeUndefined() + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId) // Timer cleared + }) + + it('should switch to mouse if follow-up event is divisible by original deltaY within 10ms', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + // First event - buffered + const event1 = new WheelEvent('wheel', { + deltaY: 10, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + + // Follow-up within 10ms with deltaY divisible by 10 + vi.spyOn(performance, 'now').mockReturnValue(505) + const event2 = new WheelEvent('wheel', { + deltaY: 30, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + + expect(pointer.detectedDevice).toBe('mouse') + expect(pointer.bufferedLinuxEvent).toBeUndefined() + }) + + it('should switch to mouse if follow-up deltaY is divisible by original (base 15)', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + // First event with base 15 + const event1 = new WheelEvent('wheel', { + deltaY: 15, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + + // Follow-up with multiple of 15 + vi.spyOn(performance, 'now').mockReturnValue(508) + const event2 = new WheelEvent('wheel', { + deltaY: 45, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should switch to mouse if original deltaY is divisible by follow-up', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + // First event with larger value + const event1 = new WheelEvent('wheel', { + deltaY: 30, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + + // Follow-up with divisor + vi.spyOn(performance, 'now').mockReturnValue(507) + const event2 = new WheelEvent('wheel', { + deltaY: 10, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should NOT switch to mouse if follow-up is not divisible', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + // First event + const event1 = new WheelEvent('wheel', { + deltaY: 10, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + + // Follow-up with non-divisible value + vi.spyOn(performance, 'now').mockReturnValue(505) + const event2 = new WheelEvent('wheel', { + deltaY: 13, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + + expect(pointer.detectedDevice).toBe('trackpad') + }) + + it('should NOT switch to mouse if follow-up comes after 10ms', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + // First event + const event1 = new WheelEvent('wheel', { + deltaY: 10, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + + // Follow-up after 10ms + vi.spyOn(performance, 'now').mockReturnValue(511) + const event2 = new WheelEvent('wheel', { + deltaY: 10, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + + expect(pointer.detectedDevice).toBe('trackpad') + }) + + it('should call clearLinuxBuffer method after 10ms timeout', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + vi.useFakeTimers() // Use fake timers just for this test + + const event = new WheelEvent('wheel', { + deltaY: 10, + deltaX: 0 + }) + pointer.isTrackpadGesture(event) + expect(pointer.bufferedLinuxEvent).toBe(event) + + // Simulate timeout firing + vi.runOnlyPendingTimers() + expect(pointer.bufferedLinuxEvent).toBeUndefined() + expect(pointer.linuxBufferTimeoutId).toBeUndefined() + + vi.useRealTimers() // Restore for other tests + }) + + it('should handle negative Linux wheel values', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + // First negative event + const event1 = new WheelEvent('wheel', { + deltaY: -15, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + + // Follow-up with same negative value + vi.spyOn(performance, 'now').mockReturnValue(505) + const event2 = new WheelEvent('wheel', { + deltaY: -15, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should handle mixed sign Linux wheel values if divisible', () => { + vi.spyOn(performance, 'now').mockReturnValue(500) + + // First positive event + const event1 = new WheelEvent('wheel', { + deltaY: 10, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + + // Follow-up with negative multiple + vi.spyOn(performance, 'now').mockReturnValue(505) + const event2 = new WheelEvent('wheel', { + deltaY: -30, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should allow buffering during 500ms cooldown as exception', () => { + pointer.detectedDevice = 'trackpad' + + // Send initial event at time 0 + vi.spyOn(performance, 'now').mockReturnValue(0) + const event1 = new WheelEvent('wheel', { + deltaY: 5, + deltaX: 2 + }) + pointer.isTrackpadGesture(event1) + + // Within cooldown at 100ms, but Linux buffering should still work + vi.spyOn(performance, 'now').mockReturnValue(100) + const event2 = new WheelEvent('wheel', { + deltaY: 10, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + expect(pointer.bufferedLinuxEvent).toBe(event2) + + // Follow-up for Linux detection at 105ms + vi.spyOn(performance, 'now').mockReturnValue(105) + const event3 = new WheelEvent('wheel', { + deltaY: 20, + deltaX: 0 + }) + pointer.isTrackpadGesture(event3) + + // Should switch despite being within original 500ms window + expect(pointer.detectedDevice).toBe('mouse') + }) + }) + + describe('Performance and Efficiency', () => { + it('should not create timers for regular wheel events', () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + pointer.detectedDevice = 'mouse' + + // Simulate rapid scrolling without Linux-like patterns + for (let i = 0; i < 100; i++) { + vi.spyOn(performance, 'now').mockReturnValue(i * 16) // 60fps scrolling + const event = new WheelEvent('wheel', { + deltaY: 120, // Clear mouse wheel value + deltaX: 0 + }) + pointer.isTrackpadGesture(event) + } + + // Should create NO timers for regular mouse wheel events + expect(setTimeoutSpy).not.toHaveBeenCalled() + }) + + it('should create at most one timer for Linux detection', () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + pointer.detectedDevice = 'trackpad' + + // Send a Linux-like event that requires buffering + vi.spyOn(performance, 'now').mockReturnValue(500) + const event1 = new WheelEvent('wheel', { deltaY: 10, deltaX: 0 }) + pointer.isTrackpadGesture(event1) + + // Should create exactly one timer + expect(setTimeoutSpy).toHaveBeenCalledTimes(1) + + // Send more regular events + for (let i = 1; i <= 10; i++) { + vi.spyOn(performance, 'now').mockReturnValue(500 + i * 100) + const event = new WheelEvent('wheel', { deltaY: 5, deltaX: 3 }) + pointer.isTrackpadGesture(event) + } + + // Still only one timer (the Linux buffer timeout) + expect(setTimeoutSpy).toHaveBeenCalledTimes(1) + }) + + it('should handle thousands of events efficiently', () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + let maxTimersCreated = 0 + + // Simulate extended scrolling session with mixed inputs + for (let i = 0; i < 10000; i++) { + vi.spyOn(performance, 'now').mockReturnValue(i) + + // Mix of different event types + const eventType = i % 3 + let event: WheelEvent + + if (eventType === 0) { + // Mouse wheel + event = new WheelEvent('wheel', { + deltaY: 120, + deltaX: 0 + }) + } else if (eventType === 1) { + // Trackpad two-finger + event = new WheelEvent('wheel', { + deltaY: Math.floor(Math.random() * 20), + deltaX: Math.floor(Math.random() * 20) + }) + } else { + // Pinch to zoom + event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: Math.random() * 10, + deltaX: 0 + }) + } + + pointer.isTrackpadGesture(event) + + // Track maximum timers created + maxTimersCreated = Math.max( + maxTimersCreated, + setTimeoutSpy.mock.calls.length + ) + } + + // Should create at most a few timers for Linux detection, not thousands + expect(maxTimersCreated).toBeLessThan(10) + }) + + it('should use minimal memory with timestamp approach', () => { + // This test verifies the implementation uses timestamps, not stored events + const initialProps = Object.keys(pointer).length + + // Process many events + for (let i = 0; i < 1000; i++) { + vi.spyOn(performance, 'now').mockReturnValue(i * 10) + const event = new WheelEvent('wheel', { + deltaY: 60 + Math.random() * 100, + deltaX: Math.random() * 50 + }) + pointer.isTrackpadGesture(event) + } + + // Should only have a few properties for tracking state + const finalProps = Object.keys(pointer).length + expect(finalProps - initialProps).toBeLessThanOrEqual(5) // Only added minimal tracking properties + + // Verify we store timestamps, not events (except Linux buffer) + expect(typeof pointer.lastWheelEventTime).toBe('number') + expect(typeof pointer.bufferedLinuxEventTime).toBe('number') + }) + + it('should handle rapid mode switching efficiently', () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + + // Rapidly switch between mouse and trackpad modes + for (let i = 0; i < 100; i++) { + const baseTime = i * 600 // Every 600ms to allow switching + + // Mouse event + vi.spyOn(performance, 'now').mockReturnValue(baseTime) + pointer.isTrackpadGesture( + new WheelEvent('wheel', { deltaY: 120, deltaX: 0 }) + ) + + // Trackpad event + vi.spyOn(performance, 'now').mockReturnValue(baseTime + 500) + pointer.isTrackpadGesture( + new WheelEvent('wheel', { deltaY: 5, deltaX: 3 }) + ) + } + + // Should create minimal or no timers despite 200 events + expect(setTimeoutSpy.mock.calls.length).toBeLessThan(5) + }) + }) + + describe('Edge Cases and Complex Scenarios', () => { + it('should handle float values correctly for mouse detection', () => { + pointer.detectedDevice = 'trackpad' + pointer.lastWheelEventTime = 0 + pointer.hasReceivedWheelEvent = true + vi.spyOn(performance, 'now').mockReturnValue(500) + + // Float value <= 80 should NOT switch from trackpad + const event1 = new WheelEvent('wheel', { + deltaY: 60.5, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + expect(pointer.detectedDevice).toBe('trackpad') + + // Float value > 80 should switch to mouse + vi.spyOn(performance, 'now').mockReturnValue(1000) // 500ms later + const event2 = new WheelEvent('wheel', { + deltaY: 80.1, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should handle integer values correctly for trackpad detection', () => { + pointer.detectedDevice = 'mouse' + pointer.lastWheelEventTime = 0 + pointer.hasReceivedWheelEvent = true + vi.spyOn(performance, 'now').mockReturnValue(500) + + // Integer values in two-finger panning + const event = new WheelEvent('wheel', { + deltaY: 5, + deltaX: 3 + }) + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('trackpad') + }) + + it('should correctly identify pinch-to-zoom with ctrlKey', () => { + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 250.5, + deltaX: 0 + }) + + // This is pinch-to-zoom but deltaY > 10, so stays as mouse on first event + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should handle rapid event sequences', () => { + pointer.detectedDevice = 'mouse' + pointer.lastWheelEventTime = 0 + + // Simulate rapid scrolling + for (let i = 0; i < 10; i++) { + vi.spyOn(performance, 'now').mockReturnValue(i * 30) // 30ms between events + const event = new WheelEvent('wheel', { + deltaY: 60, + deltaX: 0 + }) + pointer.isTrackpadGesture(event) + expect(pointer.detectedDevice).toBe('mouse') + } + }) + + it('should handle boundary values for pinch-to-zoom detection', () => { + // Test deltaY = 10 (boundary) + const event1 = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 10, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + expect(pointer.detectedDevice).toBe('mouse') + + // Reset and test deltaY = 9.999999 + pointer = new CanvasPointer(element) + const event2 = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 9.999999, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + expect(pointer.detectedDevice).toBe('trackpad') + }) + + it('should handle boundary values for mouse wheel detection', () => { + pointer.detectedDevice = 'trackpad' + pointer.lastWheelEventTime = 0 + pointer.hasReceivedWheelEvent = true + vi.spyOn(performance, 'now').mockReturnValue(500) + + // Test deltaY = 80 (boundary) + const event1 = new WheelEvent('wheel', { + deltaY: 80, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + expect(pointer.detectedDevice).toBe('trackpad') + + // Test deltaY = 80.000001 + vi.spyOn(performance, 'now').mockReturnValue(1000) // 500ms later + const event2 = new WheelEvent('wheel', { + deltaY: 80.000001, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should handle Linux wheel detection with various multiples', () => { + pointer.detectedDevice = 'trackpad' + pointer.lastWheelEventTime = 0 + pointer.hasReceivedWheelEvent = true + vi.spyOn(performance, 'now').mockReturnValue(500) + + // Test with base 10 and multiple 50 + const event1 = new WheelEvent('wheel', { + deltaY: 10, + deltaX: 0 + }) + pointer.isTrackpadGesture(event1) + + vi.spyOn(performance, 'now').mockReturnValue(505) // 5ms later + const event2 = new WheelEvent('wheel', { + deltaY: 50, + deltaX: 0 + }) + pointer.isTrackpadGesture(event2) + expect(pointer.detectedDevice).toBe('mouse') + }) + + it('should not confuse trackpad integers with Linux wheel', () => { + pointer.detectedDevice = 'trackpad' + pointer.lastWheelEventTime = 0 + pointer.hasReceivedWheelEvent = true + vi.spyOn(performance, 'now').mockReturnValue(500) + + // Trackpad two-finger panning with integers + const event1 = new WheelEvent('wheel', { + deltaY: 10, + deltaX: 5 // Non-zero deltaX + }) + pointer.isTrackpadGesture(event1) + + // Should not buffer this as Linux event + expect(pointer.bufferedLinuxEvent).toBeUndefined() + expect(pointer.detectedDevice).toBe('trackpad') + }) + }) + + describe('Input Type Validation', () => { + describe('Two-finger panning validation', () => { + it('should accept integer deltaY values', () => { + const values = [0, 1, -1, 100, -100, 999, -999] + values.forEach((deltaY) => { + const event = new WheelEvent('wheel', { + ctrlKey: false, + deltaY, + deltaX: 5 + }) + expect(Number.isInteger(event.deltaY)).toBe(true) + }) + }) + + it('should accept integer deltaX values', () => { + const values = [0, 1, -1, 100, -100, 999, -999] + values.forEach((deltaX) => { + const event = new WheelEvent('wheel', { + ctrlKey: false, + deltaY: 5, + deltaX + }) + expect(Number.isInteger(event.deltaX)).toBe(true) + }) + }) + + it('should handle ctrlKey true or false', () => { + ;[true, false].forEach((ctrlKey) => { + const event = new WheelEvent('wheel', { + ctrlKey, + deltaY: 5, + deltaX: 3 + }) + expect(typeof event.ctrlKey).toBe('boolean') + }) + }) + }) + + describe('Pinch-to-zoom validation', () => { + it('should always have ctrlKey true', () => { + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 5.5, + deltaX: 0 + }) + expect(event.ctrlKey).toBe(true) + }) + + it('should accept float deltaY values in range -1000 to 1000', () => { + const values = [-1000, -999.99, -0.1, 0, 0.1, 999.99, 1000] + values.forEach((deltaY) => { + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY, + deltaX: 0 + }) + expect(event.deltaY).toBeGreaterThanOrEqual(-1000) + expect(event.deltaY).toBeLessThanOrEqual(1000) + }) + }) + + it('should always have deltaX = 0', () => { + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 5.5, + deltaX: 0 + }) + expect(event.deltaX).toBe(0) + }) + }) + + describe('Mouse input validation', () => { + it('should accept float deltaX values in range -1000 to 1000', () => { + const values = [-1000, -500.5, 0, 500.5, 1000] + values.forEach((deltaX) => { + const event = new WheelEvent('wheel', { + deltaY: 120, + deltaX + }) + expect(event.deltaX).toBeGreaterThanOrEqual(-1000) + expect(event.deltaX).toBeLessThanOrEqual(1000) + }) + }) + + it('should have deltaY >= 60 for Windows/Mac mouse', () => { + const values = [60, 60.1, 80, 120, 240] + values.forEach((deltaY) => { + const event = new WheelEvent('wheel', { + deltaY, + deltaX: 0 + }) + expect(event.deltaY).toBeGreaterThanOrEqual(60) + }) + }) + + it('should have integer deltaY as multiples of 10 or 15 for Linux', () => { + // Base 10 multiples + const base10Values = [10, 20, 30, 40, 50, -10, -20, -30] + base10Values.forEach((deltaY) => { + expect(Number.isInteger(deltaY)).toBe(true) + // Use Math.abs to avoid JavaScript's -0 vs 0 issue with modulo on negative numbers + expect(Math.abs(deltaY) % 10).toBe(0) + }) + + // Base 15 multiples + const base15Values = [15, 30, 45, 60, -15, -30, -45] + base15Values.forEach((deltaY) => { + expect(Number.isInteger(deltaY)).toBe(true) + // Use Math.abs to avoid JavaScript's -0 vs 0 issue with modulo on negative numbers + expect(Math.abs(deltaY) % 15).toBe(0) + }) + }) + }) + + describe('Float vs Integer understanding', () => { + it('should recognize that integers are valid float values', () => { + const integerValues = [0, 1, -1, 10, -10, 100] + integerValues.forEach((value) => { + expect(Number.isInteger(value)).toBe(true) + expect(typeof value === 'number').toBe(true) // Valid as float + }) + }) + + it('should recognize that decimals are NOT valid integer values', () => { + const decimalValues = [0.1, -0.1, 10.5, -10.5, 99.99] + decimalValues.forEach((value) => { + expect(Number.isInteger(value)).toBe(false) + expect(typeof value === 'number').toBe(true) // Still valid as float + }) + }) + + it('should correctly validate pinch-to-zoom deltaY as float', () => { + // These are all valid float values for pinch-to-zoom + const validValues = [0, 1, -1, 0.5, -0.5, 999, -999, 500.123] + validValues.forEach((value) => { + const event = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: value, + deltaX: 0 + }) + expect(typeof event.deltaY === 'number').toBe(true) + expect(event.deltaY >= -1000 && event.deltaY <= 1000).toBe(true) + }) + }) + }) + }) +})