UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

443 lines (343 loc) • 15.1 kB
import { Editor } from '../../Editor' import { TLClickEventInfo, TLPointerEventInfo } from '../../types/event-types' import { ClickManager } from './ClickManager' // Mock the Editor class jest.mock('../../Editor') describe('ClickManager', () => { let editor: jest.Mocked<Editor> let clickManager: ClickManager let mockTimers: any const createPointerEvent = ( name: 'pointer_down' | 'pointer_up' | 'pointer_move', point: { x: number; y: number } = { x: 0, y: 0 } ): TLPointerEventInfo => ({ type: 'pointer', name, point, pointerId: 1, button: 0, isPen: false, target: 'canvas', shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, accelKey: false, }) beforeEach(() => { jest.useFakeTimers() mockTimers = { setTimeout: jest.fn((fn, delay) => setTimeout(fn, delay)), } editor = { timers: mockTimers, dispatch: jest.fn(), options: { doubleClickDurationMs: 300, multiClickDurationMs: 300, dragDistanceSquared: 16, coarseDragDistanceSquared: 36, }, inputs: { currentScreenPoint: { x: 0, y: 0 }, }, getInstanceState: jest.fn(() => ({ isCoarsePointer: false, })), } as any clickManager = new ClickManager(editor) }) afterEach(() => { jest.useRealTimers() jest.clearAllMocks() }) describe('constructor and initial state', () => { it('should initialize with idle state', () => { expect(clickManager.clickState).toBe('idle') }) it('should store reference to editor', () => { expect(clickManager.editor).toBe(editor) }) it('should initialize lastPointerInfo as empty object', () => { expect(clickManager.lastPointerInfo).toEqual({}) }) }) describe('single click behavior', () => { it('should handle pointer_down in idle state', () => { const pointerEvent = createPointerEvent('pointer_down', { x: 100, y: 100 }) const result = clickManager.handlePointerEvent(pointerEvent) expect(result).toBe(pointerEvent) expect(clickManager.clickState).toBe('pendingDouble') expect(clickManager.lastPointerInfo).toBe(pointerEvent) }) it('should handle pointer_up without generating click events in pending state', () => { const downEvent = createPointerEvent('pointer_down', { x: 100, y: 100 }) const upEvent = createPointerEvent('pointer_up', { x: 100, y: 100 }) clickManager.handlePointerEvent(downEvent) clickManager.handlePointerEvent(upEvent) expect(clickManager.clickState).toBe('pendingDouble') }) it('should return to idle state after timeout in pendingDouble', () => { const pointerEvent = createPointerEvent('pointer_down', { x: 100, y: 100 }) clickManager.handlePointerEvent(pointerEvent) expect(clickManager.clickState).toBe('pendingDouble') jest.advanceTimersByTime(350) expect(clickManager.clickState).toBe('idle') }) }) describe('double click detection', () => { it('should detect double click on second pointer_down', () => { const firstDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) const secondDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) clickManager.handlePointerEvent(firstDown) const result = clickManager.handlePointerEvent(secondDown) as TLClickEventInfo expect(result.type).toBe('click') expect(result.name).toBe('double_click') expect(result.phase).toBe('down') expect(clickManager.clickState).toBe('pendingTriple') }) it('should generate double_click up event on pointer_up after double_click down', () => { const firstDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) const secondDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) const secondUp = createPointerEvent('pointer_up', { x: 100, y: 100 }) clickManager.handlePointerEvent(firstDown) clickManager.handlePointerEvent(secondDown) const result = clickManager.handlePointerEvent(secondUp) as TLClickEventInfo expect(result.type).toBe('click') expect(result.name).toBe('double_click') expect(result.phase).toBe('up') }) it('should dispatch double_click settle event after timeout in pendingTriple', () => { const firstDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) const secondDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) clickManager.handlePointerEvent(firstDown) clickManager.handlePointerEvent(secondDown) jest.advanceTimersByTime(350) expect(editor.dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: 'click', name: 'double_click', phase: 'settle', }) ) expect(clickManager.clickState).toBe('idle') }) }) describe('triple and quadruple click detection', () => { it('should detect triple click on third pointer_down', () => { const firstDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) const secondDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) const thirdDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) clickManager.handlePointerEvent(firstDown) clickManager.handlePointerEvent(secondDown) const result = clickManager.handlePointerEvent(thirdDown) as TLClickEventInfo expect(result.type).toBe('click') expect(result.name).toBe('triple_click') expect(result.phase).toBe('down') expect(clickManager.clickState).toBe('pendingQuadruple') }) it('should detect quadruple click on fourth pointer_down', () => { const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) clickManager.handlePointerEvent(pointerDown) // first clickManager.handlePointerEvent(pointerDown) // second (double_click) clickManager.handlePointerEvent(pointerDown) // third (triple_click) const result = clickManager.handlePointerEvent(pointerDown) as TLClickEventInfo // fourth expect(result.type).toBe('click') expect(result.name).toBe('quadruple_click') expect(result.phase).toBe('down') expect(clickManager.clickState).toBe('pendingOverflow') }) it('should handle overflow state after quadruple click', () => { const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) clickManager.handlePointerEvent(pointerDown) // first clickManager.handlePointerEvent(pointerDown) // second clickManager.handlePointerEvent(pointerDown) // third clickManager.handlePointerEvent(pointerDown) // fourth const result = clickManager.handlePointerEvent(pointerDown) // fifth expect(result).toBe(pointerDown) expect(clickManager.clickState).toBe('overflow') }) it('should generate triple_click up event on pointer_up after triple_click down', () => { const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) const pointerUp = createPointerEvent('pointer_up', { x: 100, y: 100 }) clickManager.handlePointerEvent(pointerDown) // first clickManager.handlePointerEvent(pointerDown) // second clickManager.handlePointerEvent(pointerDown) // third const result = clickManager.handlePointerEvent(pointerUp) as TLClickEventInfo expect(result.type).toBe('click') expect(result.name).toBe('triple_click') expect(result.phase).toBe('up') }) it('should generate quadruple_click up event on pointer_up after quadruple_click down', () => { const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) const pointerUp = createPointerEvent('pointer_up', { x: 100, y: 100 }) clickManager.handlePointerEvent(pointerDown) // first clickManager.handlePointerEvent(pointerDown) // second clickManager.handlePointerEvent(pointerDown) // third clickManager.handlePointerEvent(pointerDown) // fourth const result = clickManager.handlePointerEvent(pointerUp) as TLClickEventInfo expect(result.type).toBe('click') expect(result.name).toBe('quadruple_click') expect(result.phase).toBe('up') }) }) describe('timeout behavior and settle events', () => { it('should dispatch triple_click settle event after timeout in pendingQuadruple', () => { const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) clickManager.handlePointerEvent(pointerDown) // first clickManager.handlePointerEvent(pointerDown) // second clickManager.handlePointerEvent(pointerDown) // third jest.advanceTimersByTime(350) expect(editor.dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: 'click', name: 'triple_click', phase: 'settle', }) ) expect(clickManager.clickState).toBe('idle') }) it('should dispatch quadruple_click settle event after timeout in pendingOverflow', () => { const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) clickManager.handlePointerEvent(pointerDown) // first clickManager.handlePointerEvent(pointerDown) // second clickManager.handlePointerEvent(pointerDown) // third clickManager.handlePointerEvent(pointerDown) // fourth jest.advanceTimersByTime(350) expect(editor.dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: 'click', name: 'quadruple_click', phase: 'settle', }) ) expect(clickManager.clickState).toBe('idle') }) it('should use different timeout durations for different states', () => { const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) // First click - should use doubleClickDurationMs clickManager.handlePointerEvent(pointerDown) expect(mockTimers.setTimeout).toHaveBeenCalledWith( expect.any(Function), editor.options.doubleClickDurationMs ) jest.clearAllMocks() // Second click - should use multiClickDurationMs clickManager.handlePointerEvent(pointerDown) expect(mockTimers.setTimeout).toHaveBeenCalledWith( expect.any(Function), editor.options.multiClickDurationMs ) }) }) describe('distance-based click cancellation', () => { it('should reset to idle if clicks are too far apart', () => { const firstDown = createPointerEvent('pointer_down', { x: 0, y: 0 }) const secondDown = createPointerEvent('pointer_down', { x: 50, y: 50 }) // > 40px distance clickManager.handlePointerEvent(firstDown) expect(clickManager.clickState).toBe('pendingDouble') const result = clickManager.handlePointerEvent(secondDown) expect(result).toBe(secondDown) expect(clickManager.clickState).toBe('pendingDouble') // Reset and started new sequence }) it('should continue sequence if clicks are close enough', () => { const firstDown = createPointerEvent('pointer_down', { x: 0, y: 0 }) const secondDown = createPointerEvent('pointer_down', { x: 5, y: 5 }) // < 40px distance clickManager.handlePointerEvent(firstDown) const result = clickManager.handlePointerEvent(secondDown) as TLClickEventInfo expect(result.type).toBe('click') expect(result.name).toBe('double_click') expect(clickManager.clickState).toBe('pendingTriple') }) }) describe('pointer move cancellation behavior', () => { it('should cancel click sequence on significant pointer move', () => { const downEvent = createPointerEvent('pointer_down', { x: 0, y: 0 }) const moveEvent = createPointerEvent('pointer_move', { x: 10, y: 10 }) editor.inputs.currentScreenPoint.x = 10 editor.inputs.currentScreenPoint.y = 10 clickManager.handlePointerEvent(downEvent) expect(clickManager.clickState).toBe('pendingDouble') const result = clickManager.handlePointerEvent(moveEvent) expect(result).toBe(moveEvent) expect(clickManager.clickState).toBe('idle') }) it('should use coarse drag distance for coarse pointers', () => { editor.getInstanceState.mockReturnValue({ ...editor.getInstanceState(), isCoarsePointer: true, }) const downEvent = createPointerEvent('pointer_down', { x: 0, y: 0 }) const moveEvent1 = createPointerEvent('pointer_move', { x: 1, y: 1 }) const moveEvent2 = createPointerEvent('pointer_move', { x: 5, y: 5 }) // 50 clickManager.handlePointerEvent(downEvent) expect(clickManager.clickState).toBe('pendingDouble') // Should not cancel for coarse pointer with small movement editor.inputs.currentScreenPoint.x = 1 editor.inputs.currentScreenPoint.y = 1 clickManager.handlePointerEvent(moveEvent1) expect(clickManager.clickState).toBe('pendingDouble') editor.inputs.currentScreenPoint.x = 5 editor.inputs.currentScreenPoint.y = 5 clickManager.handlePointerEvent(moveEvent2) expect(clickManager.clickState).toBe('idle') }) it('should not cancel in idle state', () => { const moveEvent = createPointerEvent('pointer_move', { x: 100, y: 100 }) editor.inputs.currentScreenPoint.x = 100 editor.inputs.currentScreenPoint.y = 100 clickManager.handlePointerEvent(moveEvent) expect(clickManager.clickState).toBe('idle') }) }) describe('cancelDoubleClickTimeout method', () => { it('should clear timeout and reset state to idle', () => { const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) clickManager.handlePointerEvent(pointerDown) expect(clickManager.clickState).toBe('pendingDouble') clickManager.cancelDoubleClickTimeout() expect(clickManager.clickState).toBe('idle') }) it('should prevent timeout callback from executing after cancellation', () => { const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) clickManager.handlePointerEvent(pointerDown) clickManager.handlePointerEvent(pointerDown) // double click expect(clickManager.clickState).toBe('pendingTriple') clickManager.cancelDoubleClickTimeout() // Advance time - should not dispatch settle event jest.advanceTimersByTime(350) expect(editor.dispatch).not.toHaveBeenCalled() expect(clickManager.clickState).toBe('idle') }) }) describe('edge cases', () => { it('should handle null click state gracefully', () => { // Force null state ;(clickManager as any)._clickState = null const pointerEvent = createPointerEvent('pointer_down', { x: 100, y: 100 }) const result = clickManager.handlePointerEvent(pointerEvent) expect(result).toBe(pointerEvent) }) it('should handle missing previous screen point', () => { const firstDown = createPointerEvent('pointer_down', { x: 0, y: 0 }) // Clear previous point ;(clickManager as any)._previousScreenPoint = undefined const result = clickManager.handlePointerEvent(firstDown) expect(result).toBe(firstDown) expect(clickManager.clickState).toBe('pendingDouble') }) it('should handle overflow state correctly', () => { const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) const pointerUp = createPointerEvent('pointer_up', { x: 100, y: 100 }) // Get to overflow state clickManager.handlePointerEvent(pointerDown) // 1 clickManager.handlePointerEvent(pointerDown) // 2 clickManager.handlePointerEvent(pointerDown) // 3 clickManager.handlePointerEvent(pointerDown) // 4 clickManager.handlePointerEvent(pointerDown) // 5 -> overflow expect(clickManager.clickState).toBe('overflow') // pointer_up in overflow should just return the event clickManager.handlePointerEvent(pointerUp) }) }) })