import { act, screen } from '@testing-library/react'
import {
BaseBoxShapeTool,
BaseBoxShapeUtil,
Editor,
HTMLContainer,
IndexKey,
TLAssetStore,
TLShape,
TLShapeId,
TldrawEditor,
createShapeId,
createTLStore,
noop,
toRichText,
} from '@tldraw/editor'
import { StrictMode } from 'react'
import { vi } from 'vitest'
import { defaultShapeUtils } from '../lib/defaultShapeUtils'
import { defaultTools } from '../lib/defaultTools'
import { createDrawSegments } from '../lib/utils/test-helpers'
import { defaultAddFontsFromNode, tipTapDefaultExtensions } from '../lib/utils/text/richText'
import {
renderTldrawComponent,
renderTldrawComponentWithEditor,
} from './testutils/renderTldrawComponent'
function checkAllShapes(editor: Editor, shapes: string[]) {
expect(Object.keys(editor!.shapeUtils)).toStrictEqual(shapes)
}
const options = {
text: {
addFontsFromNode: defaultAddFontsFromNode,
tipTapConfig: {
extensions: tipTapDefaultExtensions,
},
},
}
describe('', () => {
it('Renders without crashing', async () => {
await renderTldrawComponent(, {
waitForPatterns: false,
})
await screen.findByTestId('canvas')
})
it('Creates its own store with core shapes', async () => {
let editor: Editor
await renderTldrawComponent(
{
editor = e
}}
initialState="select"
tools={defaultTools}
/>,
{ waitForPatterns: false }
)
checkAllShapes(editor!, ['group'])
})
it('Can be created with default shapes', async () => {
let editor: Editor
await renderTldrawComponent(
{
editor = e
}}
/>,
{ waitForPatterns: false }
)
expect(editor!).toBeTruthy()
checkAllShapes(editor!, ['group'])
})
it('Renders with an external store', async () => {
const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
await renderTldrawComponent(
{
expect(editor.store).toBe(store)
}}
/>,
{ waitForPatterns: false }
)
})
it('throws if the store has different shapes to the ones passed in', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(noop)
// expect(() =>
// render(
// {
// throw error
// },
// }}
// >
//
//
// )
// ).toThrowErrorMatchingInlineSnapshot(
// `"Editor and store have different shapes: \\"draw\\" was passed into the editor but not the schema"`
// )
// expect(() =>
// render(
// {
// throw error
// },
// }}
// >
//
//
// )
// ).toThrowErrorMatchingInlineSnapshot(
// `"Editor and store have different shapes: \\"draw\\" is present in the store schema but not provided to the editor"`
// )
spy.mockRestore()
})
it('Accepts fresh versions of store and calls `onMount` for each one', async () => {
const initialStore = createTLStore({ shapeUtils: [], bindingUtils: [] })
const onMount = vi.fn()
const rendered = await renderTldrawComponent(
,
{ waitForPatterns: false }
)
const initialEditor = onMount.mock.lastCall![0]
vi.spyOn(initialEditor, 'dispose')
expect(initialEditor.store).toBe(initialStore)
// re-render with the same store:
rendered.rerender(
)
// not called again:
expect(onMount).toHaveBeenCalledTimes(1)
// re-render with a new store:
const newStore = createTLStore({ shapeUtils: [], bindingUtils: [] })
rendered.rerender(
)
await rendered.findAllByTestId('canvas')
expect(initialEditor.dispose).toHaveBeenCalledTimes(1)
expect(onMount).toHaveBeenCalledTimes(2)
expect(onMount.mock.lastCall![0].store).toBe(newStore)
})
it('Renders the canvas and shapes', async () => {
let editor = {} as Editor
await renderTldrawComponent(
{
editor = editorApp
}}
options={options}
/>,
{ waitForPatterns: false }
)
expect(editor).toBeTruthy()
await act(async () => {
editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } })
})
// Test all shape types except group
const shapeTypesToTest = [
{ type: 'arrow' as const, props: { start: { x: 0, y: 0 }, end: { x: 100, y: 100 } } },
{ type: 'bookmark' as const, props: { w: 100, h: 100, url: 'https://example.com' } },
{
type: 'draw' as const,
props: { segments: createDrawSegments([[{ x: 0, y: 0, z: 0.5 }]]) },
},
{ type: 'embed' as const, props: { w: 100, h: 100, url: 'https://example.com' } },
{ type: 'frame' as const, props: { w: 100, h: 100 } },
{ type: 'geo' as const, props: { w: 100, h: 100, geo: 'rectangle' as const } },
{
type: 'highlight' as const,
props: { segments: createDrawSegments([[{ x: 0, y: 0, z: 0.5 }]]) },
},
{ type: 'image' as const, props: { w: 100, h: 100 } },
{
type: 'line' as const,
props: {
points: {
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: 100, y: 100 },
},
},
},
{ type: 'note' as const, props: { richText: toRichText('test') } },
{ type: 'text' as const, props: { w: 100, richText: toRichText('test') } },
{ type: 'video' as const, props: { w: 100, h: 100 } },
]
const shapeIds: TLShapeId[] = []
for (let i = 0; i < shapeTypesToTest.length; i++) {
const shapeConfig = shapeTypesToTest[i]
const id = createShapeId()
shapeIds.push(id)
await act(async () => {
editor.createShapes([
{
id,
...shapeConfig,
x: i * 150, // Space them out horizontally
y: 0,
},
])
})
// Does the shape exist?
const shape = editor.getShape(id)
expect(shape).toBeTruthy()
expect(shape?.type).toBe(shapeConfig.type)
// Check that all shapes rendered without error boundaries
expect(
document.querySelectorAll('.tl-shape-error-boundary'),
`${shapeConfig.type} had an error while rendering`
).toHaveLength(0)
}
// Check that all shape components are rendering
expect(document.querySelectorAll('.tl-shape').length).toBeGreaterThanOrEqual(
shapeTypesToTest.length
)
// Check that the canvas overlays element is present (indicators render here too)
expect(document.querySelector('.tl-canvas-overlays')).toBeTruthy()
// Select one of the shapes (the note shape)
const noteShapeId = shapeIds[9] // note is at index 9
await act(async () => editor.select(noteShapeId))
expect(editor.getSelectedShapeIds().length).toBe(1)
expect(editor.getSelectedShapeIds()[0]).toBe(noteShapeId)
// Select the eraser tool...
await act(async () => editor.setCurrentTool('eraser'))
// Is the editor's current tool correct?
expect(editor.getCurrentToolId()).toBe('eraser')
})
it('Renders selection overlays without TldrawUiContextProvider', async () => {
// Unmock useTranslation so we test the real implementation.
// (setupVitest.js globally mocks it to prevent errors in other tests)
const actual = await vi.importActual<
typeof import('../lib/ui/hooks/useTranslation/useTranslation')
>('../lib/ui/hooks/useTranslation/useTranslation')
const translationModule = await import('../lib/ui/hooks/useTranslation/useTranslation')
const spy = vi
.spyOn(translationModule, 'useTranslation')
.mockImplementation(actual.useTranslation)
const errors: unknown[] = []
let editor = {} as Editor
await renderTldrawComponent(
{
errors.push(error)
return
},
}}
onMount={(editorApp) => {
editor = editorApp
}}
options={options}
/>,
{ waitForPatterns: false }
)
await act(async () => {
editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } })
})
const id = createShapeId()
await act(async () => {
editor.createShapes([
{
id,
type: 'geo',
props: { w: 100, h: 100 },
},
])
})
// Select the shape — this triggers the selection foreground to render
// with resize/rotate handles that use useTranslation()
await act(async () => editor.select(id))
expect(editor.getSelectedShapeIds()).toHaveLength(1)
// Verify no errors were caught by the error boundary
// (useTranslation would throw without the fix, which the error boundary catches)
expect(errors).toHaveLength(0)
expect(document.querySelector('[data-testid="test-error-fallback"]')).toBeNull()
// Selection foreground is now rendered via the OverlayUtil canvas system,
// so we verify via the canvas-overlays element instead of the old SVG element
expect(document.querySelector('.tl-canvas-overlays')).toBeTruthy()
spy.mockRestore()
})
it('renders correctly in strict mode', async () => {
const editorInstances = new Set()
const onMount = vi.fn((editor: Editor) => {
editorInstances.add(editor)
})
await renderTldrawComponent(
,
{ waitForPatterns: false }
)
// we should only get one editor instance
expect(editorInstances.size).toBe(1)
// strict mode may cause onMount to be called twice, but the important
// thing is that we always get the same editor instance
expect(onMount).toHaveBeenCalled()
})
it('allows updating camera options without re-creating the editor', async () => {
const editors: Editor[] = []
const onMount = vi.fn((editor: Editor) => {
if (!editors.includes(editor)) editors.push(editor)
})
const renderer = await renderTldrawComponent(, {
waitForPatterns: false,
})
expect(editors.length).toBe(1)
expect(editors[0].getCameraOptions().isLocked).toBe(false)
renderer.rerender()
expect(editors.length).toBe(1)
expect(editors[0].getCameraOptions().isLocked).toBe(true)
})
it('will populate the store from the snapshot prop', async () => {
const snapshot = {
schema: {
schemaVersion: 2,
sequences: {
'com.tldraw.store': 4,
'com.tldraw.asset': 1,
'com.tldraw.camera': 1,
'com.tldraw.document': 2,
'com.tldraw.instance': 25,
'com.tldraw.instance_page_state': 5,
'com.tldraw.page': 1,
'com.tldraw.instance_presence': 5,
'com.tldraw.pointer': 1,
'com.tldraw.shape': 4,
'com.tldraw.asset.bookmark': 2,
'com.tldraw.asset.image': 5,
'com.tldraw.asset.video': 5,
'com.tldraw.shape.arrow': 5,
'com.tldraw.shape.bookmark': 2,
'com.tldraw.shape.draw': 2,
'com.tldraw.shape.embed': 4,
'com.tldraw.shape.frame': 0,
'com.tldraw.shape.geo': 9,
'com.tldraw.shape.group': 0,
'com.tldraw.shape.highlight': 1,
'com.tldraw.shape.image': 4,
'com.tldraw.shape.line': 5,
'com.tldraw.shape.note': 7,
'com.tldraw.shape.text': 2,
'com.tldraw.shape.video': 2,
'com.tldraw.binding.arrow': 0,
},
},
store: {
'document:document': {
gridSize: 10,
name: '',
meta: {},
id: 'document:document',
typeName: 'document',
},
'page:page': { meta: {}, id: 'page:page', name: 'Page 1', index: 'a1', typeName: 'page' },
'shape:SxHfVyCVdM4Ryl27eJNRD': {
x: 608.718221918489,
y: 298.97020222415506,
rotation: 0,
isLocked: false,
opacity: 1,
meta: {},
id: 'shape:SxHfVyCVdM4Ryl27eJNRD',
type: 'geo',
props: {
w: 152.74967383200806,
h: 134.57489438369782,
geo: 'rectangle',
color: 'black',
labelColor: 'black',
fill: 'none',
dash: 'draw',
size: 'm',
font: 'draw',
text: '',
align: 'middle',
verticalAlign: 'middle',
growY: 0,
url: '',
scale: 1,
},
parentId: 'page:page',
index: 'a1',
typeName: 'shape',
},
},
} as any
const { editor } = await renderTldrawComponentWithEditor(
(onMount) => (
),
{ waitForPatterns: true }
)
act(() => editor.selectAll())
expect(editor.getSelectedShapes()).toMatchObject([
{
id: 'shape:SxHfVyCVdM4Ryl27eJNRD',
type: 'geo',
props: { w: 152.74967383200806, h: 134.57489438369782 },
},
])
})
it('passes through the `assets` prop when creating its own in-memory store', async () => {
const myUploadFn = vi.fn()
const assetStore: TLAssetStore = { upload: myUploadFn }
const { editor } = await renderTldrawComponentWithEditor(
(onMount) => (
),
{ waitForPatterns: true }
)
expect(editor.store.props.assets.upload).toBe(myUploadFn)
})
it('passes through the `assets` prop when using `persistenceKey`', async () => {
const myUploadFn = vi.fn()
const assetStore: TLAssetStore = { upload: myUploadFn }
const { editor } = await renderTldrawComponentWithEditor(
(onMount) => (
),
{ waitForPatterns: true }
)
expect(editor.store.props.assets.upload).toBe(myUploadFn)
})
it('will not re-create the editor if re-rendered with identical options', async () => {
const onMount = vi.fn()
const renderer = await renderTldrawComponent(
,
{
waitForPatterns: false,
}
)
expect(onMount).toHaveBeenCalledTimes(1)
renderer.rerender()
expect(onMount).toHaveBeenCalledTimes(1)
})
})
const CARD_TYPE = 'card'
declare module '@tldraw/tlschema' {
export interface TLGlobalShapePropsMap {
[CARD_TYPE]: { w: number; h: number }
}
}
type CardShape = TLShape
describe('Custom shapes', () => {
class CardUtil extends BaseBoxShapeUtil {
static override type = CARD_TYPE
override isAspectRatioLocked(shape: CardShape) {
return false
}
override canResize(shape: CardShape) {
return true
}
override getDefaultProps(): CardShape['props'] {
return {
w: 300,
h: 300,
}
}
component(shape: CardShape) {
return (
{shape.props.w.toFixed()}x{shape.props.h.toFixed()}
)
}
getIndicatorPath(shape: CardShape) {
const path = new Path2D()
path.rect(0, 0, shape.props.w, shape.props.h)
return path
}
}
class CardTool extends BaseBoxShapeTool {
static override id = 'card'
static override initial = 'idle'
override shapeType = 'card' as const
}
const tools = [CardTool]
const shapeUtils = [CardUtil]
it('Uses custom shapes', async () => {
let editor = {} as Editor
await renderTldrawComponent(
{
editor = editorApp
}}
/>,
{ waitForPatterns: false }
)
expect(editor).toBeTruthy()
await act(async () => {
editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } })
})
expect(editor.shapeUtils.card).toBeTruthy()
checkAllShapes(editor, ['group', 'card'])
const id = createShapeId()
await act(async () => {
editor.createShapes([
{
id,
type: 'card',
props: { w: 100, h: 100 },
},
])
})
// Does the shape exist?
expect(editor.getShape(id)).toMatchObject({
id,
type: 'card',
x: 0,
y: 0,
opacity: 1,
props: { w: 100, h: 100 },
})
// Is the shape's component rendering?
expect(await screen.findByTestId('card-shape')).toBeTruthy()
// Select the shape
await act(async () => editor.select(id))
// Indicators are now rendered via the canvas overlay system (not DOM),
// so we verify selection state instead of a DOM element
expect(editor.getSelectedShapeIds()).toEqual([id])
// Select the tool...
await act(async () => editor.setCurrentTool('card'))
// Is the editor's current tool correct?
expect(editor.getCurrentToolId()).toBe('card')
})
})