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') }) })