UNPKG

tldraw

Version:

A tiny little drawing editor.

1,151 lines (1,052 loc) • 34.6 kB
import { Box, DEFAULT_CAMERA_OPTIONS, Vec, createShapeId } from '@tldraw/editor' import { vi } from 'vitest' import { TestEditor } from '../TestEditor' let editor: TestEditor beforeEach(() => { editor = new TestEditor({ options: { edgeScrollDelay: 0, edgeScrollEaseDuration: 0, }, }) editor.updateViewportScreenBounds(new Box(0, 0, 1600, 900)) editor.user.updateUserPreferences({ inputMode: null }) editor._transformPointerDownSpy.mockRestore() editor._transformPointerUpSpy.mockRestore() }) const wheelEvent = { type: 'wheel', name: 'wheel', delta: new Vec(0, 0, 0), point: new Vec(0, 0), shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, accelKey: false, } as const const pinchEvent = { type: 'pinch', name: 'pinch', delta: new Vec(0, 0, 1), point: new Vec(0, 0), shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, accelKey: false, } as const const keyBoardEvent = { type: 'keyboard', name: 'key_down', key: ' ', code: 'Space', shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, accelKey: false, } as const describe('With default options', () => { beforeEach(() => { editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS }) }) it('pans', () => { expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) editor .dispatch({ ...pinchEvent, name: 'pinch_start', }) .forceTick() editor .dispatch({ ...pinchEvent, name: 'pinch', delta: new Vec(100, -10), }) .forceTick() editor .dispatch({ ...pinchEvent, name: 'pinch_end', }) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 100, y: -10, z: 1 }) }) it('pans with wheel', () => { expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) editor.dispatch({ ...wheelEvent, delta: new Vec(5, 10) }).forceTick() expect(editor.getCamera()).toMatchObject({ x: 5, y: 10, z: 1 }) }) it('zooms with wheel', () => { expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) // zoom in 10% editor.dispatch({ ...wheelEvent, delta: new Vec(0, 0, -0.1), ctrlKey: true }).forceTick() expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 0.9 }) // zoom out 10% editor.dispatch({ ...wheelEvent, delta: new Vec(0, 0, 0.1), ctrlKey: true }).forceTick() expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 0.99 }) }) it('pinch zooms', () => { expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) // zoom in editor .dispatch({ ...pinchEvent, name: 'pinch_start', }) .forceTick() editor .dispatch({ ...pinchEvent, name: 'pinch', point: new Vec(0, 0, 0.5), }) .forceTick() editor .dispatch({ ...pinchEvent, name: 'pinch_end', }) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 0.5 }) // zoom out editor .dispatch({ ...pinchEvent, name: 'pinch_start', }) .forceTick() editor .dispatch({ ...pinchEvent, name: 'pinch', point: new Vec(0, 0, 1), }) .forceTick() editor .dispatch({ ...pinchEvent, name: 'pinch_end', }) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) }) }) it('Sets the camera options', () => { const optionsA = { ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2 } editor.setCameraOptions(optionsA) expect(editor.getCameraOptions()).toMatchObject(optionsA) }) describe('CameraOptions.wheelBehavior', () => { it('Pans when wheel behavior is pan', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, wheelBehavior: 'pan' }) .dispatch({ ...wheelEvent, delta: new Vec(5, 10), }) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 5, y: 10, z: 1 }) }) it('Zooms when wheel behavior is zoom', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, wheelBehavior: 'zoom' }) .dispatch({ ...wheelEvent, delta: new Vec(0, 0, 0), }) .forceTick() expect(editor.getCamera()).toMatchObject({ z: 1 }) editor .dispatch({ ...wheelEvent, delta: new Vec(0, 1, 0), }) .forceTick() expect(editor.getCamera()).toMatchObject({ z: 1.01 }) editor .dispatch({ ...wheelEvent, delta: new Vec(0, -1, 0), }) .forceTick() expect(editor.getCamera()).toMatchObject({ z: 0.9999 }) // zooming is non-linear }) it('When wheelBehavior is pan, ctrl key zooms', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, wheelBehavior: 'pan' }) .dispatch({ ...wheelEvent, delta: new Vec(0, 5, 0.01), ctrlKey: true, // zooms }) .forceTick() expect(editor.getCamera()).toMatchObject({ z: 1.01 }) }) it('When wheelBehavior is zoom, ctrl key pans', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, wheelBehavior: 'zoom' }) .dispatch({ ...wheelEvent, delta: new Vec(0, 5, 0.01), ctrlKey: true, // zooms }) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 0, y: 5, z: 1 }) }) it('When input mode is set, camera wheel behavior is ignored', () => { editor.user.updateUserPreferences({ inputMode: 'trackpad' }) editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, wheelBehavior: 'zoom' }) .dispatch({ ...wheelEvent, delta: new Vec(0, 5, 0.01), }) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 0, y: 5, z: 1 }) }) }) describe('Zoom direction inversion', () => { it('should invert zoom when input mode is mouse and preference is enabled', () => { editor.user.updateUserPreferences({ inputMode: 'mouse', isZoomDirectionInverted: true }) const cameraBefore = editor.getCamera().z editor .dispatch({ ...wheelEvent, delta: new Vec(0, -1, 0), point: new Vec(100, 100), }) .forceTick() const cameraAfter = editor.getCamera().z expect(cameraAfter).toBeGreaterThan(cameraBefore) }) it('should NOT invert zoom when input mode is auto', () => { editor.user.updateUserPreferences({ inputMode: null, isZoomDirectionInverted: true }) editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, wheelBehavior: 'zoom' }) const cameraBefore = editor.getCamera().z editor .dispatch({ ...wheelEvent, delta: new Vec(0, -1, 0), point: new Vec(100, 100), }) .forceTick() const cameraAfter = editor.getCamera().z expect(cameraAfter).toBeLessThan(cameraBefore) }) it('should zoom normally when input mode is mouse but preference is disabled', () => { editor.user.updateUserPreferences({ inputMode: 'mouse', isZoomDirectionInverted: false }) const cameraBefore = editor.getCamera().z editor .dispatch({ ...wheelEvent, delta: new Vec(0, -1, 0), point: new Vec(100, 100), }) .forceTick() const cameraAfter = editor.getCamera().z expect(cameraAfter).toBeLessThan(cameraBefore) }) }) describe('CameraOptions.panSpeed', () => { it('Affects wheel panning (2x)', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2, wheelBehavior: 'pan' }) .dispatch({ ...wheelEvent, delta: new Vec(5, 10), }) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 10, y: 20, z: 1 }) }) it('Affects wheel panning (.5x)', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 0.5, wheelBehavior: 'pan' }) .dispatch({ ...wheelEvent, delta: new Vec(5, 10), }) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 2.5, y: 5, z: 1 }) }) it('Does not affect zoom mouse wheeling', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2, wheelBehavior: 'zoom' }) .dispatch({ ...wheelEvent, delta: new Vec(0, 1, 0), }) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1.01 }) // 1 + 1 }) it('Does not affect hand tool panning', () => { editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2 }) editor.setCurrentTool('hand').pointerDown(0, 0).pointerMove(50, 50) expect(editor.getCamera()).toMatchObject({ x: 50, y: 50, z: 1 }) }) it('Does not affect spacebar panning (2x)', () => { editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2 }) editor .dispatch({ ...keyBoardEvent, key: ' ', code: 'Space' }) .pointerDown(0, 0) .pointerMove(5, 10) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 5, y: 10, z: 1 }) }) it('Does not affect spacebar panning (0.5x)', () => { editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 0.5 }) editor .dispatch({ ...keyBoardEvent, key: ' ', code: 'Space' }) .pointerDown(0, 0) .pointerMove(5, 10) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 5, y: 10, z: 1 }) }) it('Does not affect edge scroll panning', () => { const shapeId = createShapeId() const viewportScreenBounds = editor.getViewportScreenBounds() editor.user.updateUserPreferences({ edgeScrollSpeed: 1 }) editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2 }) .createShape({ id: shapeId, type: 'geo', x: 10, y: 10 }) .select(shapeId) const shape = editor.getSelectedShapes()[0] // Move shape far beyond bounds to trigger edge scrolling at maximum speed expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) // pointerMove calls forceTick() internally, so we don't need an extra forceTick() call editor.pointerDown(shape.x, shape.y, shapeId).forceTick().pointerMove(-5000, -5000) // At maximum speed and a zoom level of 1, the camera should move by 25px per tick if the screen // is wider than 1000 pixels, or by 25 * 0.612px if it is smaller. const newX = viewportScreenBounds.w < 1000 ? 25 * 0.612 : 25 const newY = viewportScreenBounds.h < 1000 ? 25 * 0.612 : 25 expect(editor.getCamera()).toMatchObject({ x: newX, y: newY, z: 1 }) }) }) describe('CameraOptions.zoomSpeed', () => { it('Affects wheel zooming (2x)', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 2, wheelBehavior: 'zoom' }) .dispatch({ ...wheelEvent, delta: new Vec(0, 1, 0), }) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1.02 }) // 1 + (.01 * 2) }) it('Affects wheel zooming (.5x)', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 0.5, wheelBehavior: 'zoom' }) .dispatch({ ...wheelEvent, delta: new Vec(0, 1, 0), }) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1.005 }) // 1 + (.01 * .5) }) it('Does not affect mouse wheel panning', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 0.5, wheelBehavior: 'pan' }) .dispatch({ ...wheelEvent, delta: new Vec(5, 10), }) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 5, y: 10, z: 1 }) }) it('Does not affect pinch zooming (2x)', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 2 }) .dispatch({ ...pinchEvent, name: 'pinch_start', }) .forceTick() editor.dispatch({ ...pinchEvent, name: 'pinch', delta: new Vec(0, 0, 1), }) editor.forceTick() editor.dispatch({ ...pinchEvent, name: 'pinch_end', }) editor.forceTick() expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) }) it('Does not affect pinch zooming (0.5x)', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 0.5 }) .dispatch({ ...pinchEvent, name: 'pinch_start', }) .forceTick() editor.dispatch({ ...pinchEvent, name: 'pinch', delta: new Vec(0, 0, 1), }) editor.forceTick() editor.dispatch({ ...pinchEvent, name: 'pinch_end', }) editor.forceTick() expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) }) it('Does not affect zoom tool zooming (2x)', () => { editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 2 }) expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) editor.setCurrentTool('zoom').click() vi.advanceTimersByTime(300) expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 2 }) }) it('Does not affect zoom tool zooming (0.5x)', () => { editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 0.5 }) expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) editor.setCurrentTool('zoom').click() vi.advanceTimersByTime(300) expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 2 }) }) it('Does not affect editor zoom method (2x)', () => { editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 2 }) expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) editor.zoomIn(new Vec(0, 0), { immediate: true }) expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 2 }) editor.zoomOut(new Vec(0, 0), { immediate: true }) expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) }) it('Does not affect editor zoom method (0.5x)', () => { editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 0.5 }) expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) editor.zoomIn(new Vec(0, 0), { immediate: true }) expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 2 }) editor.zoomOut(new Vec(0, 0), { immediate: true }) expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) }) }) describe('CameraOptions.isLocked', () => { it('Pans when unlocked', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, isLocked: false }) .dispatch({ ...wheelEvent, delta: new Vec(5, 10), }) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 5, y: 10, z: 1 }) editor.pan(new Vec(10, 10)) expect(editor.getCamera()).toMatchObject({ x: 15, y: 20, z: 1 }) }) it('Does not pan when locked', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, isLocked: true }) .dispatch({ ...wheelEvent, delta: new Vec(5, 10), }) .forceTick() expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) editor.pan(new Vec(10, 10)) expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) }) it('Zooms when unlocked', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, isLocked: false }) .dispatch({ ...wheelEvent, delta: new Vec(0, 0, 0.01), ctrlKey: true, }) .forceTick() expect(editor.getCamera()).toMatchObject({ z: 1.01 }) editor.zoomIn(undefined, { immediate: true }) expect(editor.getCamera()).toMatchObject({ z: 2 }) }) it('Does not zoom when locked', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, isLocked: true }) .dispatch({ ...wheelEvent, delta: new Vec(0, 0, 0.01), ctrlKey: true, }) .forceTick() expect(editor.getCamera()).toMatchObject({ z: 1 }) editor.zoomIn(undefined, { immediate: true }) expect(editor.getCamera()).toMatchObject({ z: 1 }) }) }) // zoom steps are tested in zoom in / zoom out method describe('CameraOptions.zoomSteps', () => { it('Does not zoom past max zoom step', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSteps: [0.5, 1, 2] }) .setCamera(new Vec(0, 0, 100), { immediate: true }) expect(editor.getZoomLevel()).toBe(2) editor.zoomIn(undefined, { immediate: true }) expect(editor.getZoomLevel()).toBe(2) }) it('Does not zoom below min zoom step', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSteps: [0.5, 1, 2] }) .setCamera(new Vec(0, 0, 0), { immediate: true }) expect(editor.getZoomLevel()).toBe(0.5) editor.zoomOut(undefined, { immediate: true }) expect(editor.getZoomLevel()).toBe(0.5) }) it('Zooms between zoom steps', () => { editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSteps: [0.5, 1, 2] }) expect(editor.getZoomLevel()).toBe(1) editor.zoomIn(undefined, { immediate: true }) expect(editor.getZoomLevel()).toBe(2) editor.zoomOut(undefined, { immediate: true }) expect(editor.getZoomLevel()).toBe(1) editor.zoomOut(undefined, { immediate: true }) expect(editor.getZoomLevel()).toBe(0.5) editor.zoomIn(undefined, { immediate: true }) expect(editor.getZoomLevel()).toBe(1) }) }) // constraints?: { // /** The bounds (in page space) of the constrained space */ // bounds: BoxModel // /** The padding inside of the viewport (in screen space) */ // padding: VecLike // /** The origin for placement. Used to position the bounds within the viewport when an axis is fixed or contained and zoom is below the axis fit. */ // origin: VecLike // /** The camera's initial zoom, used also when the camera is reset. */ // initialZoom: 'fit-min' | 'fit-max' | 'fit-x' | 'fit-y' | 'default' // /** The camera's base for its zoom steps. */ // baseZoom: 'fit-min' | 'fit-max' | 'fit-x' | 'fit-y' | 'default' // /** The behavior for the constraints on the x axis. */ // behavior: // | 'free' // | 'contain' // | 'inside' // | 'outside' // | 'fixed' // | { // x: 'contain' | 'inside' | 'outside' | 'fixed' | 'free' // y: 'contain' | 'inside' | 'outside' | 'fixed' | 'free' // } // } const DEFAULT_CONSTRAINTS = { bounds: { x: 0, y: 0, w: 1200, h: 800 }, padding: { x: 0, y: 0 }, origin: { x: 0.5, y: 0.5 }, initialZoom: 'default', baseZoom: 'default', behavior: 'free', } as const describe('When constraints are free', () => { beforeEach(() => { editor.updateViewportScreenBounds(new Box(0, 0, 1600, 900)) editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'free', }, }) }) it('starts at 1 zoom', () => { expect(editor.getZoomLevel()).toBe(1) }) it('pans freely', () => { editor.pan(new Vec(100, 100)) expect(editor.getCamera()).toMatchObject({ x: 100, y: 100, z: 1 }) editor.pan(new Vec(5000, 5000)) expect(editor.getCamera()).toMatchObject({ x: 5100, y: 5100, z: 1 }) }) it('zooms onto mouse position', () => { editor.pointerMove(100, 100) expect(editor.inputs.getCurrentPagePoint()).toMatchObject({ x: 100, y: 100 }) editor.zoomIn(editor.inputs.getCurrentScreenPoint(), { immediate: true }) expect(editor.inputs.getCurrentPagePoint()).toMatchObject({ x: 100, y: 100 }) editor.zoomOut(editor.inputs.getCurrentScreenPoint(), { immediate: true }) expect(editor.inputs.getCurrentPagePoint()).toMatchObject({ x: 100, y: 100 }) }) }) describe('When constraints are contain', () => { beforeEach(() => { editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', }, }) }) it('resets zoom to 1', () => { editor.zoomIn(undefined, { immediate: true }) editor.zoomIn(undefined, { immediate: true }) editor.resetZoom() expect(editor.getZoomLevel()).toBe(1) }) it('does not pan when below the fit zoom', () => { editor.pan(new Vec(100, 100)) expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 }) editor.pan(new Vec(5000, 5000)) expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 }) }) }) describe('Zoom reset positions based on origin', () => { it('Default .5, .5 origin', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, initialZoom: 'default', }, }) .setCamera(editor.getCamera(), { reset: true }) expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 }) editor.zoomIn().resetZoom().forceTick() expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 }) }) it('0 0 origin', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0, y: 0 }, initialZoom: 'default', }, }) .setCamera(editor.getCamera(), { reset: true }) expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) editor.zoomIn().resetZoom().forceTick() expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) }) it('1 1 origin', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 1, y: 1 }, initialZoom: 'default', }, }) .setCamera(editor.getCamera(), { reset: true }) expect(editor.getCamera()).toMatchObject({ x: 400, y: 100, z: 1 }) editor.zoomIn().resetZoom().forceTick() expect(editor.getCamera()).toMatchObject({ x: 400, y: 100, z: 1 }) }) }) describe('CameraOptions.constraints.initialZoom + behavior', () => { it('When fit is default', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, initialZoom: 'default', }, }) .setCamera(editor.getCamera(), { reset: true }) expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 }) editor.zoomIn().resetZoom().forceTick() expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 }) }) it('When fit is fit-max', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, initialZoom: 'fit-max', }, }) .setCamera(editor.getCamera(), { reset: true }) // y should be 0 because the viewport width is bigger than the height expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2) editor.zoomIn().resetZoom().forceTick() expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2) editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, bounds: { x: 0, y: 0, w: 800, h: 1200 }, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, initialZoom: 'fit-max', }, }) .setCamera(editor.getCamera(), { reset: true }) // y should be 0 because the viewport width is bigger than the height expect(editor.getCamera()).toCloselyMatchObject({ x: 666.66, y: 0, z: 0.75 }, 2) editor.zoomIn().resetZoom().forceTick() expect(editor.getCamera()).toCloselyMatchObject({ x: 666.66, y: 0, z: 0.75 }, 2) // The proportions of the bounds don't matter, it's the proportion of the viewport that decides which axis gets fit editor.updateViewportScreenBounds(new Box(0, 0, 900, 1600)) editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, initialZoom: 'fit-max', }, }) .setCamera(editor.getCamera(), { reset: true }) expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: 666.66, z: 0.75 }, 2) editor.zoomIn().resetZoom().forceTick() expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: 666.66, z: 0.75 }, 2) }) it('When fit is fit-min', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, initialZoom: 'fit-min', }, }) .setCamera(editor.getCamera(), { reset: true }) // x should be 0 because the viewport width is bigger than the height expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -62.5, z: 1.333 }, 2) editor.zoomIn().resetZoom().forceTick() expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -62.5, z: 1.333 }, 2) editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, bounds: { x: 0, y: 0, w: 800, h: 1200 }, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, initialZoom: 'fit-min', }, }) .setCamera(editor.getCamera(), { reset: true }) // x should be 0 because the viewport width is bigger than the height expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -375, z: 2 }, 2) editor.zoomIn().resetZoom().forceTick() expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -375, z: 2 }, 2) // The proportions of the bounds don't matter, it's the proportion of the viewport that decides which axis gets fit editor.updateViewportScreenBounds(new Box(0, 0, 900, 1600)) editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, initialZoom: 'fit-min', }, }) .setCamera(editor.getCamera(), { reset: true }) expect(editor.getCamera()).toCloselyMatchObject({ x: -375, y: 0, z: 2 }, 2) editor.zoomIn().resetZoom().forceTick() expect(editor.getCamera()).toCloselyMatchObject({ x: -375, y: 0, z: 2 }, 2) }) it('When fit is fit-min-100', () => { editor.updateViewportScreenBounds(new Box(0, 0, 1600, 900)) editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, initialZoom: 'fit-min-100', }, }) .setCamera(editor.getCamera(), { reset: true }) // Max 1 on initial / reset expect(editor.getCamera()).toCloselyMatchObject({ x: 200, y: 50, z: 1 }, 2) // Min is regular editor.updateViewportScreenBounds(new Box(0, 0, 800, 450)) editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, initialZoom: 'fit-min-100', }, }) .setCamera(editor.getCamera(), { reset: true }) expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -62.5, z: 0.66 }, 2) }) }) describe('Padding', () => { it('sets when padding is zero', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, padding: { x: 0, y: 0 }, initialZoom: 'fit-max', }, }) .setCamera(editor.getCamera(), { reset: true }) // y should be 0 because the viewport width is bigger than the height expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2) editor.zoomIn().resetZoom().forceTick() expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2) }) it('sets when padding is 100, 0', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, padding: { x: 100, y: 0 }, initialZoom: 'fit-max', }, }) .setCamera(editor.getCamera(), { reset: true }) // no change because the horizontal axis has extra space available expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2) editor.zoomIn().resetZoom().forceTick() expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2) editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, padding: { x: 200, y: 0 }, initialZoom: 'fit-max', }, }) .setCamera(editor.getCamera(), { reset: true }) // now we're pinching expect(editor.getCamera()).toCloselyMatchObject({ x: 200, y: 50, z: 1 }, 2) editor.zoomIn().resetZoom().forceTick() expect(editor.getCamera()).toCloselyMatchObject({ x: 200, y: 50, z: 1 }, 2) }) it('sets when padding is 0 x 100', () => { editor .setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, padding: { x: 0, y: 100 }, initialZoom: 'fit-max', }, }) .setCamera(editor.getCamera(), { reset: true, immediate: true }) // y should be 0 because the viewport width is bigger than the height expect(editor.getCamera()).toCloselyMatchObject({ x: 314.28, y: 114.28, z: 0.875 }, 2) editor.zoomIn().resetZoom().forceTick() expect(editor.getCamera()).toCloselyMatchObject({ x: 314.28, y: 114.28, z: 0.875 }, 2) }) }) describe('Contain behavior', () => { it('Locks axis until the bounds are smaller than the padded viewport, then allows "inside" panning', () => { const boundsW = 1600 const boundsH = 900 const padding = 100 editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, bounds: { x: 0, y: 0, w: boundsW, h: boundsH }, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, padding: { x: padding, y: padding }, initialZoom: 'fit-max', baseZoom: 'fit-max', }, }) editor.setCamera(editor.getCamera(), { reset: true }) const baseZoom = 700 / 900 const x = padding / baseZoom - boundsW + (boundsW - padding * 2) / baseZoom - padding const y = padding / baseZoom - boundsH + (boundsH - padding * 2) / baseZoom expect(editor.getCamera()).toCloselyMatchObject({ x, y, z: baseZoom }, 5) // We should not be able to pan editor.pan(new Vec(-10000, -10000)) expect(editor.getCamera()).toCloselyMatchObject({ x, y, z: baseZoom }, 5) // But we can zoom editor.zoomOut() const newZoom = 0.5 * baseZoom const newX = padding / newZoom - boundsW + (boundsW - padding * 2) / newZoom - boundsW / 2 - padding * 2 const newY = padding / newZoom - boundsH + (boundsH - padding * 2) / newZoom - boundsH / 2 const newCamera = { x: newX, y: newY, z: newZoom } expect(editor.getCamera()).toCloselyMatchObject(newCamera, 5) // Panning is still locked editor.pan(new Vec(-10000, -10000)) expect(editor.getCamera()).toCloselyMatchObject(newCamera, 5) // Zooming to within bounds will allow us to pan editor.zoomIn().zoomIn() const camera = editor.getCamera() editor.pan(new Vec(-10000, -10000)) expect(editor.getCamera()).not.toMatchObject(camera) }) }) describe('Inside behavior', () => { it('Allows panning that keeps the bounds inside of the padded viewport', () => { const bounds = editor.getViewportScreenBounds() // set the constraints to be inside the viewport + 100px padding editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, bounds: { x: 0, y: 0, w: bounds.w, h: bounds.h }, behavior: 'inside', origin: { x: 0, y: 0 }, padding: { x: 100, y: 100 }, initialZoom: 'fit-min', }, }) expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) // panning far outside of the bounds editor.pan(new Vec(-10000, -10000)) // should be clamped to the bounds + padding expect(editor.getCamera()).toMatchObject({ x: -100, y: -100, z: 1 }) // panning to the opposite direction, far outside of the bounds editor.pan(new Vec(10000, 10000)) // should be clamped to the bounds + padding expect(editor.getCamera()).toMatchObject({ x: 100, y: 100, z: 1 }) }) }) describe('Outside behavior', () => { it('Allows panning that keeps the bounds adjacent to the padded viewport', () => { const bounds = editor.getViewportScreenBounds() // set the constraints to be the viewport + 100px padding editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, bounds: { x: 0, y: 0, w: bounds.w, h: bounds.h }, behavior: 'outside', origin: { x: 0, y: 0 }, padding: { x: 100, y: 100 }, initialZoom: 'fit-min', }, }) expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 }) // panning far outside of the bounds editor.pan(new Vec(-10000, -10000)) // should be clamped so that the far edge of the bounds is adjacent to the viewport + padding expect(editor.getCamera()).toMatchObject({ x: -bounds.w + 100, y: -bounds.h + 100, z: 1 }) // panning to the opposite direction, far outside of the bounds editor.pan(new Vec(10000, 10000)) // should be clamped so that the far edge of the bounds is adjacent to the viewport + padding expect(editor.getCamera()).toMatchObject({ x: bounds.w - 100, y: bounds.h - 100, z: 1 }) }) }) describe('Allows mixed values for x and y', () => { it('Allows different values to be set for x and y behaviour', () => { editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: { x: 'inside', y: 'outside' }, initialZoom: 'fit-x', baseZoom: 'fit-x', }, }) editor.setCamera(editor.getCamera(), { reset: true }) const camera = editor.getCamera() editor.pan(new Vec(-100, 0)) // no change when panning on x axis because it's set to inside expect(editor.getCamera()).toMatchObject(camera) editor.pan(new Vec(0, -100)) // change when panning on y axis because it's set to outside expect(editor.getCamera()).toMatchObject({ ...camera, y: camera.y - 100 / camera.z }) editor.pan(new Vec(0, -1000000)) // clamped to the bounds expect(editor.getCamera()).toMatchObject({ ...camera, y: -800 }) }) it('Allows different values to be set for x and y origin', () => { editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0, y: 1 }, initialZoom: 'default', }, }) editor.setCamera(editor.getCamera(), { reset: true }) const camera = editor.getCamera() editor.zoomOut() // zooms out and keeps the bounds in the bottom left of the viewport, so no change on x axis expect(editor.getCamera()).toMatchObject({ x: 0, y: camera.y + 900, z: 0.5 }) }) }) test('it animated towards the constrained viewport rather than the given viewport', () => { // @ts-expect-error const mockAnimateToViewport = (editor._animateToViewport = vi.fn()) editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, behavior: 'contain', origin: { x: 0.5, y: 0.5 }, padding: { x: 100, y: 100 }, initialZoom: 'fit-max', }, }) editor.setCamera(new Vec(-1000000, -1000000), { animation: { duration: 4000 } }) expect(mockAnimateToViewport).toHaveBeenCalledTimes(1) expect(mockAnimateToViewport.mock.calls[0][0]).toMatchInlineSnapshot(` Box { "h": 900, "w": 1600, "x": -200, "y": -0, } `) }) test('calling setCameraOptions will apply the new constraints', () => { editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, }, }) editor.setCamera(new Vec(-1000000, -1000000, 1)) const camera = editor.getCamera() expect(camera).toMatchObject({ x: -1000000, y: -1000000, z: 1 }) editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, constraints: { ...DEFAULT_CONSTRAINTS, bounds: { x: 0, y: 0, w: 1600, h: 900 }, behavior: 'contain', }, }) expect(editor.getCamera()).toMatchInlineSnapshot(` { "id": "camera:page:page", "meta": {}, "typeName": "camera", "x": 0, "y": 0, "z": 1, } `) })