import { TLDrawShape } from '@tldraw/editor' import { TestEditor } from '../../../test/TestEditor' import { Drawing } from './toolStates/Drawing' let editor: TestEditor beforeEach(() => { editor = new TestEditor() }) afterEach(() => { editor?.dispose() }) function getDrawingState(): Drawing { return editor.root.children!['draw'].children!['drawing'] as Drawing } describe('When in the idle state', () => { it('Returns to select on cancel', () => { editor.setCurrentTool('draw') editor.expectToBeIn('draw.idle') editor.cancel() editor.expectToBeIn('select.idle') }) it('Enters the drawing state on pointer down', () => { editor.setCurrentTool('draw') editor.pointerDown(50, 50) editor.expectToBeIn('draw.drawing') }) }) describe('When in the drawing state', () => { it('Returns to idle on cancel', () => { editor.setCurrentTool('draw') editor.pointerDown(50, 50) editor.cancel() editor.expectToBeIn('draw.idle') }) it('Returns to idle on complete', () => { editor.setCurrentTool('draw') editor.pointerDown(50, 50) editor.pointerUp(50, 50) editor.expectToBeIn('draw.idle') editor.pointerDown(50, 50) editor.pointerMove(55, 55) editor.pointerMove(60, 60) editor.pointerUp(60, 60) editor.expectToBeIn('draw.idle') }) }) describe('zoomOnEnter', () => { it('Captures the zoom level when entering the drawing state', () => { editor.setCamera({ x: 0, y: 0, z: 2 }) editor.setCurrentTool('draw') editor.pointerDown(50, 50) const drawing = getDrawingState() expect(drawing.zoomOnEnter).toBe(2) }) it('Uses the zoom level at entry, not the current zoom level', () => { editor.setCamera({ x: 0, y: 0, z: 3 }) editor.setCurrentTool('draw') editor.pointerDown(50, 50) const drawing = getDrawingState() expect(drawing.zoomOnEnter).toBe(3) // Change zoom mid-stroke editor.setCamera({ x: 0, y: 0, z: 1 }) editor.pointerMove(60, 60) // zoomOnEnter should still reflect the zoom at entry expect(drawing.zoomOnEnter).toBe(3) }) it('Defaults to 1 when zoom is at default level', () => { editor.setCurrentTool('draw') editor.pointerDown(50, 50) const drawing = getDrawingState() expect(drawing.zoomOnEnter).toBe(1) }) }) describe('Close threshold with zoom', () => { function drawNearlyClosedShape(gap: number) { // Draw a shape that loops back near the start point // Start at origin, go right, down, left, then almost back to start editor.setCurrentTool('draw') editor.pointerDown(100, 100) editor.pointerMove(200, 100) editor.pointerMove(200, 200) editor.pointerMove(100, 200) // Come back near the start with the specified gap editor.pointerMove(100 + gap, 100) editor.pointerUp() const shapes = editor.getCurrentPageShapes() return shapes[shapes.length - 1] as TLDrawShape } it('Closes a shape when the endpoint is near the start at zoom=1', () => { const shape = drawNearlyClosedShape(2) expect(shape.props.isClosed).toBe(true) }) it('Closes a small shape with densely sampled points', () => { // Match the point density of the reported shape that exposed the length calculation bug. const samples = 476 const radius = 20 const center = { x: 100, y: 100 } editor.setCurrentTool('draw') editor.pointerDown(center.x + radius, center.y) for (let i = 1; i <= samples; i++) { const angle = (i / samples) * Math.PI * 2 editor.pointerMove(center.x + Math.cos(angle) * radius, center.y + Math.sin(angle) * radius) } editor.pointerUp() const shapes = editor.getCurrentPageShapes() as TLDrawShape[] expect(shapes).toHaveLength(1) expect(shapes[0].props.isClosed).toBe(true) }) it('Does not close a shape when the endpoint is far from the start', () => { const shape = drawNearlyClosedShape(50) expect(shape.props.isClosed).toBe(false) }) it('Has a larger close threshold at low zoom levels', () => { // threshold = 6 + 2*sqrt(strokeWidth*0.8) + 100/(1+(zoom/0.18)^3) // At zoom=0.1: threshold ≈ 95 page units // At zoom=1: threshold ≈ 10 page units // Note: screen gap * (1/zoom) = page gap // At zoom=1, screen gap of 15px = 15 page units > threshold ~10 → NOT closed editor.setCamera({ x: 0, y: 0, z: 1 }) const shapeAtZoom1 = drawNearlyClosedShape(15) expect(shapeAtZoom1.props.isClosed).toBe(false) // At zoom=0.1, screen gap of 5px = 50 page units < threshold ~95 → CLOSED // The larger page-space threshold at low zoom makes closing easier editor.setCamera({ x: 0, y: 0, z: 0.1 }) const shapeAtLowZoom = drawNearlyClosedShape(5) expect(shapeAtLowZoom.props.isClosed).toBe(true) }) it('Has a small close threshold at high zoom levels', () => { // At zoom=5: threshold ≈ 9.4 page units // Screen gap of 60px = 12 page units > threshold → NOT closed editor.setCamera({ x: 0, y: 0, z: 5 }) const shape = drawNearlyClosedShape(60) expect(shape.props.isClosed).toBe(false) }) it('Uses a different threshold formula when dynamic resize mode is enabled', () => { editor.user.updateUserPreferences({ isDynamicSizeMode: true }) // In dynamic resize mode, threshold = (strokeWidth + 2) * scale // where scale = 1/zoom. At zoom=0.1, scale=10, strokeWidth(m)=3.5 // threshold = (3.5+2)*10 = 55 page units // Screen gap of 3px at zoom=0.1 = 30 page units < 55 → CLOSED editor.setCamera({ x: 0, y: 0, z: 0.1 }) const shapeAtLowZoom = drawNearlyClosedShape(3) expect(shapeAtLowZoom.props.isClosed).toBe(true) }) it('Closes on the straight segment that returns to the start, not one update later', () => { const shift = { shiftKey: true } editor.setCurrentTool('draw') editor.pointerDown(100, 100, shift) editor.pointerMove(200, 100, shift) editor.pointerUp(200, 100, shift) // shift-click extends the previous line with a new straight segment editor.pointerDown(200, 100, shift) editor.pointerMove(150, 180, shift) editor.pointerUp(150, 180, shift) editor.pointerDown(150, 180, shift) editor.pointerMove(100, 100, shift) const shapes = editor.getCurrentPageShapes() as TLDrawShape[] expect(shapes).toHaveLength(1) expect(shapes[0].props.isClosed).toBe(true) }) it('Closes when the first straight segment after a free segment returns to the start', () => { editor.setCurrentTool('draw') editor.pointerDown(100, 100) editor.pointerMove(200, 100) editor.pointerMove(150, 180) editor.pointerMove(150, 140) expect((editor.getCurrentPageShapes()[0] as TLDrawShape).props.isClosed).toBe(false) // pressing shift starts a straight segment from here; the move that commits it lands on the start editor.keyDown('Shift') editor.pointerMove(100, 100, { shiftKey: true }) const shapes = editor.getCurrentPageShapes() as TLDrawShape[] expect(shapes).toHaveLength(1) expect(shapes[0].props.segments.map((s) => s.type)).toEqual(['free', 'straight']) expect(shapes[0].props.isClosed).toBe(true) }) it('Closes when the final straight segment pushes a short stroke over the minimum length', () => { // size m stroke width is 3.5, so a stroke needs > 14 page units of length to close editor.setCurrentTool('draw') editor.pointerDown(100, 100) editor.pointerMove(108, 100) expect((editor.getCurrentPageShapes()[0] as TLDrawShape).props.isClosed).toBe(false) editor.keyDown('Shift') editor.pointerMove(100, 100, { shiftKey: true }) const shape = editor.getCurrentPageShapes()[0] as TLDrawShape expect(shape.props.segments.map((s) => s.type)).toEqual(['free', 'straight']) expect(shape.props.isClosed).toBe(true) }) it('Does not close highlight shapes regardless of zoom', () => { editor.setCamera({ x: 0, y: 0, z: 0.1 }) editor.setCurrentTool('highlight') editor.pointerDown(100, 100) editor.pointerMove(200, 100) editor.pointerMove(200, 200) editor.pointerMove(100, 200) editor.pointerMove(102, 100) editor.pointerUp() const shapes = editor.getCurrentPageShapes() const shape = shapes[shapes.length - 1] // Highlight shapes don't have isClosed expect((shape as any).props.isClosed).toBeUndefined() }) }) describe('Line length with straight segments', () => { // size m stroke width is 3.5, so a stroke needs > 14 page units of length to close it('Counts a dragged straight segment once, not once per pointer move', () => { const shift = { shiftKey: true } editor.setCurrentTool('draw') editor.pointerDown(100, 100) editor.pointerMove(102, 100) editor.keyDown('Shift') for (let x = 103; x <= 120; x++) editor.pointerMove(x, 100, shift) // 2 free + 18 straight expect(getDrawingState().currentLineLength).toBeCloseTo(20) // drag the straight segment back onto the start: 2 free + 2 straight for (let x = 119; x >= 100; x--) editor.pointerMove(x, 100, shift) expect(getDrawingState().currentLineLength).toBeCloseTo(4) editor.pointerUp(100, 100, shift) expect((editor.getCurrentPageShapes()[0] as TLDrawShape).props.isClosed).toBe(false) }) it('Counts a dragged shift-click segment once, not once per pointer move', () => { const shift = { shiftKey: true } editor.setCurrentTool('draw') editor.pointerDown(100, 100).pointerUp(100, 100) editor.pointerDown(105, 100, shift) for (let x = 106; x <= 120; x++) editor.pointerMove(x, 100, shift) for (let x = 119; x >= 105; x--) editor.pointerMove(x, 100, shift) editor.pointerUp(105, 100, shift) expect(getDrawingState().currentLineLength).toBeCloseTo(5) // shift-click back onto the start editor.pointerDown(100, 100, shift).pointerUp(100, 100, shift) expect(getDrawingState().currentLineLength).toBeCloseTo(10) expect((editor.getCurrentPageShapes()[0] as TLDrawShape).props.isClosed).toBe(false) }) })