@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
375 lines (296 loc) • 12.3 kB
text/typescript
import { Box } from '../../../primitives/Box'
import { Vec } from '../../../primitives/Vec'
import { Editor } from '../../Editor'
import { EdgeScrollManager } from './EdgeScrollManager'
// Mock the Editor class
jest.mock('../../Editor')
describe('EdgeScrollManager', () => {
let editor: jest.Mocked<
Editor & {
user: { getEdgeScrollSpeed: jest.Mock }
getCamera: jest.Mock
getCameraOptions: jest.Mock
getZoomLevel: jest.Mock
getViewportScreenBounds: jest.Mock
}
>
let edgeScrollManager: EdgeScrollManager
beforeEach(() => {
editor = {
options: {
edgeScrollDelay: 200,
edgeScrollEaseDuration: 200,
edgeScrollSpeed: 25,
edgeScrollDistance: 8,
coarsePointerWidth: 12,
},
inputs: {
currentScreenPoint: new Vec(500, 300),
isDragging: true,
isPanning: false,
},
user: {
getEdgeScrollSpeed: jest.fn(() => 1),
},
getViewportScreenBounds: jest.fn(() => new Box(0, 0, 1000, 600)),
getInstanceState: jest.fn(
() =>
({
isCoarsePointer: false,
insets: [false, false, false, false], // [top, right, bottom, left]
}) as any
),
getCameraOptions: jest.fn(() => ({
isLocked: false,
panSpeed: 1,
zoomSpeed: 1,
zoomSteps: [1],
wheelBehavior: 'pan' as const,
})),
getZoomLevel: jest.fn(() => 1),
getCamera: jest.fn(() => new Vec(0, 0, 1)),
setCamera: jest.fn(),
} as any
edgeScrollManager = new EdgeScrollManager(editor as any)
})
afterEach(() => {
jest.clearAllMocks()
})
describe('constructor and initialization', () => {
it('should initialize with editor reference', () => {
expect(edgeScrollManager.editor).toBe(editor)
})
it('should initialize edge scrolling state as false', () => {
// Access private properties for testing
expect((edgeScrollManager as any)._isEdgeScrolling).toBe(false)
expect((edgeScrollManager as any)._edgeScrollDuration).toBe(-1)
})
})
describe('basic edge scrolling behavior', () => {
it('should not trigger edge scrolling when pointer is in center', () => {
editor.inputs.currentScreenPoint = new Vec(500, 300)
edgeScrollManager.updateEdgeScrolling(16)
expect((edgeScrollManager as any)._isEdgeScrolling).toBe(false)
expect(editor.setCamera).not.toHaveBeenCalled()
})
it('should start edge scrolling when pointer is near edge', () => {
editor.inputs.currentScreenPoint = new Vec(5, 300)
edgeScrollManager.updateEdgeScrolling(16)
expect((edgeScrollManager as any)._isEdgeScrolling).toBe(true)
expect((edgeScrollManager as any)._edgeScrollDuration).toBe(16)
})
it('should stop edge scrolling when pointer moves away from edge', () => {
// Start edge scrolling
editor.inputs.currentScreenPoint = new Vec(5, 300)
edgeScrollManager.updateEdgeScrolling(16)
expect((edgeScrollManager as any)._isEdgeScrolling).toBe(true)
// Move pointer to center
editor.inputs.currentScreenPoint = new Vec(500, 300)
edgeScrollManager.updateEdgeScrolling(16)
expect((edgeScrollManager as any)._isEdgeScrolling).toBe(false)
expect((edgeScrollManager as any)._edgeScrollDuration).toBe(0)
})
it('should respect edge scroll delay', () => {
editor.inputs.currentScreenPoint = new Vec(5, 300)
// First update - should not scroll yet due to delay
edgeScrollManager.updateEdgeScrolling(100)
expect(editor.setCamera).not.toHaveBeenCalled()
// Second update - should trigger scrolling after delay
edgeScrollManager.updateEdgeScrolling(150)
expect(editor.setCamera).toHaveBeenCalled()
})
})
describe('edge proximity detection', () => {
it('should detect left edge proximity', () => {
editor.inputs.currentScreenPoint = new Vec(5, 300)
edgeScrollManager.updateEdgeScrolling(300) // Enough to trigger after delay
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.x).toBeGreaterThan(0) // Should scroll right when near left edge
})
it('should detect right edge proximity', () => {
editor.inputs.currentScreenPoint = new Vec(995, 300)
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.x).toBeLessThan(0) // Should scroll left when near right edge
})
it('should detect top edge proximity', () => {
editor.inputs.currentScreenPoint = new Vec(500, 5)
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.y).toBeGreaterThan(0) // Should scroll down when near top edge
})
it('should detect bottom edge proximity', () => {
editor.inputs.currentScreenPoint = new Vec(500, 595)
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.y).toBeLessThan(0) // Should scroll up when near bottom edge
})
it('should handle corner proximity (both x and y)', () => {
editor.inputs.currentScreenPoint = new Vec(5, 5)
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.x).toBeGreaterThan(0) // Should scroll right
expect(callArgs.y).toBeGreaterThan(0) // Should scroll down
})
})
describe('coarse pointer handling', () => {
it('should account for coarse pointer width', () => {
editor.getInstanceState.mockReturnValue({
isCoarsePointer: true,
insets: [false, false, false, false],
} as any)
editor.inputs.currentScreenPoint = new Vec(15, 300)
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
})
it('should not trigger edge scrolling for fine pointer at same position', () => {
editor.getInstanceState.mockReturnValue({
isCoarsePointer: false,
insets: [false, false, false, false],
} as any)
editor.inputs.currentScreenPoint = new Vec(15, 300)
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).not.toHaveBeenCalled()
})
})
describe('camera movement conditions', () => {
it('should not move camera when not dragging', () => {
editor.inputs.isDragging = false
editor.inputs.currentScreenPoint = new Vec(5, 300)
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).not.toHaveBeenCalled()
})
it('should not move camera when panning', () => {
editor.inputs.isPanning = true
editor.inputs.currentScreenPoint = new Vec(5, 300)
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).not.toHaveBeenCalled()
})
it('should not move camera when camera is locked', () => {
editor.getCameraOptions.mockReturnValue({
isLocked: true,
panSpeed: 1,
zoomSpeed: 1,
zoomSteps: [1],
wheelBehavior: 'pan' as const,
})
editor.inputs.currentScreenPoint = new Vec(5, 300)
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).not.toHaveBeenCalled()
})
})
describe('camera movement calculation', () => {
it('should calculate scroll speed based on user preference', () => {
editor.user.getEdgeScrollSpeed.mockReturnValue(2)
editor.inputs.currentScreenPoint = new Vec(5, 300)
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.x).toBeGreaterThan(0) // Should scroll when user speed is > 0
})
it('should apply screen size factor for small screens', () => {
editor.getViewportScreenBounds.mockReturnValue(new Box(0, 0, 800, 600))
editor.inputs.currentScreenPoint = new Vec(5, 300)
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
})
it('should adjust scroll speed based on zoom level', () => {
editor.getZoomLevel.mockReturnValue(2)
editor.inputs.currentScreenPoint = new Vec(5, 300)
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
// Higher zoom should result in smaller camera movement
expect(Math.abs(callArgs.x)).toBeLessThan(25)
})
it('should add scroll delta to current camera position', () => {
const currentCamera = new Vec(100, 200, 1)
editor.getCamera.mockReturnValue(currentCamera)
editor.inputs.currentScreenPoint = new Vec(5, 5)
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.x).toBeGreaterThan(100) // Should be added to current position
expect(callArgs.y).toBeGreaterThan(200) // Should be added to current position
expect(callArgs.z).toBe(1) // Z should remain unchanged
})
})
describe('proximity factor calculation', () => {
it('should return 0 when not near any edge', () => {
editor.inputs.currentScreenPoint = new Vec(500, 300)
edgeScrollManager.updateEdgeScrolling(16)
expect(editor.setCamera).not.toHaveBeenCalled()
})
it('should cap proximity factor at 1', () => {
editor.inputs.currentScreenPoint = new Vec(0, 300)
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
// The proximity factor should be capped, so movement shouldn't be infinite
})
})
describe('edge cases and error handling', () => {
it('should handle negative elapsed time', () => {
editor.inputs.currentScreenPoint = new Vec(5, 300)
expect(() => edgeScrollManager.updateEdgeScrolling(-16)).not.toThrow()
})
it('should handle very large elapsed time', () => {
editor.inputs.currentScreenPoint = new Vec(5, 300)
expect(() => edgeScrollManager.updateEdgeScrolling(100000)).not.toThrow()
})
it('should handle zero user edge scroll speed', () => {
editor.user.getEdgeScrollSpeed.mockReturnValue(0)
editor.inputs.currentScreenPoint = new Vec(5, 300)
edgeScrollManager.updateEdgeScrolling(300)
if (editor.setCamera.mock.calls.length > 0) {
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.x).toBe(0)
expect(callArgs.y).toBe(0)
}
})
it('should handle extreme zoom levels', () => {
editor.inputs.currentScreenPoint = new Vec(5, 300)
editor.getZoomLevel.mockReturnValue(0.01) // Very zoomed out
expect(() => edgeScrollManager.updateEdgeScrolling(300)).not.toThrow()
editor.getZoomLevel.mockReturnValue(100) // Very zoomed in
expect(() => edgeScrollManager.updateEdgeScrolling(300)).not.toThrow()
})
})
describe('state transitions', () => {
it('should properly transition from not scrolling to scrolling', () => {
// Start with no edge scrolling
editor.inputs.currentScreenPoint = new Vec(500, 300)
edgeScrollManager.updateEdgeScrolling(16)
expect((edgeScrollManager as any)._isEdgeScrolling).toBe(false)
// Move to edge
editor.inputs.currentScreenPoint = new Vec(5, 300)
edgeScrollManager.updateEdgeScrolling(16)
expect((edgeScrollManager as any)._isEdgeScrolling).toBe(true)
expect((edgeScrollManager as any)._edgeScrollDuration).toBe(16)
})
it('should accumulate edge scroll duration over multiple updates', () => {
editor.inputs.currentScreenPoint = new Vec(5, 300)
edgeScrollManager.updateEdgeScrolling(50)
expect((edgeScrollManager as any)._edgeScrollDuration).toBe(50)
edgeScrollManager.updateEdgeScrolling(30)
expect((edgeScrollManager as any)._edgeScrollDuration).toBe(80)
edgeScrollManager.updateEdgeScrolling(25)
expect((edgeScrollManager as any)._edgeScrollDuration).toBe(105)
})
it('should reset duration when stopping edge scroll', () => {
// Start edge scrolling
editor.inputs.currentScreenPoint = new Vec(5, 300)
edgeScrollManager.updateEdgeScrolling(100)
expect((edgeScrollManager as any)._edgeScrollDuration).toBe(100)
// Stop edge scrolling
editor.inputs.currentScreenPoint = new Vec(500, 300)
edgeScrollManager.updateEdgeScrolling(16)
expect((edgeScrollManager as any)._isEdgeScrolling).toBe(false)
expect((edgeScrollManager as any)._edgeScrollDuration).toBe(0)
})
})
})