import { act, screen } from '@testing-library/react' import { BaseBoxShapeUtil, Editor, StateNode, TLStateNodeConstructor } from '@tldraw/editor' import { useState } from 'react' import { renderTldrawComponent, renderTldrawComponentWithEditor, } from '../test/testutils/renderTldrawComponent' import { Tldraw } from './Tldraw' describe('', () => { it('Renders without crashing', async () => { await renderTldrawComponent(
, { waitForPatterns: true } ) await screen.findByTestId('canvas-1') }) it('Doesnt cause re-render loops with unstable shape utils + tools', async () => { function TestComponent() { const [_, setEditor] = useState(null) return (
) } await renderTldrawComponent(, { waitForPatterns: true }) await screen.findByTestId('canvas-1') }) it('Doesnt cause re-render loops when shape utils change', async () => { class FakeShapeUtil1 extends BaseBoxShapeUtil { static override type = 'fake' as const override getDefaultProps() { throw new Error('Method not implemented.') } override component(_: any) { throw new Error('Method not implemented.') } override getIndicatorPath(_: any): undefined { throw new Error('Method not implemented.') } } class FakeShapeUtil2 extends BaseBoxShapeUtil { static override type = 'fake' as const override getDefaultProps() { throw new Error('Method not implemented.') } override component(_: any) { throw new Error('Method not implemented.') } override getIndicatorPath(_: any): undefined { throw new Error('Method not implemented.') } } const rendered = await renderTldrawComponent(
, { waitForPatterns: false } ) await screen.findByTestId('canvas-1') await act(async () => { rendered.rerender(
) }) await screen.findByTestId('canvas-2') }) it('correctly merges custom tools with default tools, allowing custom tools to override defaults', async () => { // Create a custom tool that overrides a default tool class CustomSelectTool extends StateNode { static override id = 'select' // This should override the default select tool static override initial = 'idle' static override children(): TLStateNodeConstructor[] { return [CustomIdleState] } } class CustomIdleState extends StateNode { static override id = 'idle' } // Create a custom tool that doesn't conflict with defaults class CustomTool extends StateNode { static override id = 'custom-tool' static override initial = 'idle' static override children(): TLStateNodeConstructor[] { return [CustomToolIdleState] } } class CustomToolIdleState extends StateNode { static override id = 'idle' } let editor: Editor await renderTldrawComponent( { editor = e }} />, { waitForPatterns: false } ) // Verify that the custom select tool overrides the default select tool expect(editor!.root.children!['select']).toBeInstanceOf(CustomSelectTool) // Verify that the custom tool is also available expect(editor!.root.children!['custom-tool']).toBeInstanceOf(CustomTool) // Verify that other default tools are still available expect(editor!.root.children!['eraser']).toBeDefined() expect(editor!.root.children!['hand']).toBeDefined() expect(editor!.root.children!['zoom']).toBeDefined() }) it('keyboard shortcuts work when hideUi is true', async () => { const { editor } = await renderTldrawComponentWithEditor( (onMount) => , { waitForPatterns: false } ) // Focus the editor so keyboard shortcuts are active await act(async () => { editor.focus() }) // Start on select tool expect(editor.getCurrentToolId()).toBe('select') await act(async () => { document.body.dispatchEvent( new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', bubbles: true }) ) document.body.dispatchEvent( new KeyboardEvent('keyup', { key: 'd', code: 'KeyD', bubbles: true }) ) }) // Should now be on draw tool expect(editor.getCurrentToolId()).toBe('draw') await act(async () => { document.body.dispatchEvent( new KeyboardEvent('keydown', { key: 'h', code: 'KeyH', bubbles: true }) ) document.body.dispatchEvent( new KeyboardEvent('keyup', { key: 'h', code: 'KeyH', bubbles: true }) ) }) // Should now be on hand tool expect(editor.getCurrentToolId()).toBe('hand') }) it('matches typed-character shortcuts on alternative Latin layouts (Dvorak)', async () => { const { editor } = await renderTldrawComponentWithEditor( (onMount) => , { waitForPatterns: false } ) await act(async () => { editor.focus() }) // Dvorak's 'd' lives at the physical position QWERTY calls 'KeyH'. The user typed 'd'; // matching by typed character (event.key) selects the draw tool regardless of physical position. await act(async () => { document.body.dispatchEvent( new KeyboardEvent('keydown', { key: 'd', code: 'KeyH', bubbles: true }) ) document.body.dispatchEvent( new KeyboardEvent('keyup', { key: 'd', code: 'KeyH', bubbles: true }) ) }) expect(editor.getCurrentToolId()).toBe('draw') // Dvorak's 'e' lives at the physical position QWERTY calls 'KeyD'. await act(async () => { document.body.dispatchEvent( new KeyboardEvent('keydown', { key: 'e', code: 'KeyD', bubbles: true }) ) document.body.dispatchEvent( new KeyboardEvent('keyup', { key: 'e', code: 'KeyD', bubbles: true }) ) }) expect(editor.getCurrentToolId()).toBe('eraser') }) it('falls back to physical key for non-Latin layouts (Cyrillic)', async () => { const { editor } = await renderTldrawComponentWithEditor( (onMount) => , { waitForPatterns: false } ) await act(async () => { editor.focus() }) // Cyrillic users pressing the physical KeyA position type 'ф'. Since the typed character // has no ASCII shortcut, we fall back to event.code's US-QWERTY equivalent ('a') — which // activates the arrow tool just as it would on a QWERTY keyboard. await act(async () => { document.body.dispatchEvent( new KeyboardEvent('keydown', { key: 'ф', code: 'KeyA', bubbles: true }) ) document.body.dispatchEvent( new KeyboardEvent('keyup', { key: 'ф', code: 'KeyA', bubbles: true }) ) }) expect(editor.getCurrentToolId()).toBe('arrow') }) it('matches modifier shortcuts (cmd/ctrl) regardless of layout', async () => { const { editor } = await renderTldrawComponentWithEditor( (onMount) => , { waitForPatterns: false } ) await act(async () => { editor.focus() }) await act(async () => { editor.createShape({ id: 'shape:test' as any, type: 'geo', x: 0, y: 0 }) }) expect(editor.getCurrentPageShapeIds().size).toBe(1) await act(async () => { document.body.dispatchEvent( new KeyboardEvent('keydown', { key: 'z', code: 'KeyZ', metaKey: true, bubbles: true, }) ) document.body.dispatchEvent( new KeyboardEvent('keyup', { key: 'z', code: 'KeyZ', metaKey: true, bubbles: true, }) ) }) // cmd+z should undo the shape creation (kbd is 'cmd+z,ctrl+z') expect(editor.getCurrentPageShapeIds().size).toBe(0) }) it('matches the comma key for the temporary zoom shortcut on Dvorak', async () => { const { editor } = await renderTldrawComponentWithEditor( (onMount) => , { waitForPatterns: false } ) await act(async () => { editor.focus() }) // Dvorak's comma is at the physical KeyW position. With layout-independent matching by // event.code (the v4 hotkeys-js bug) the library would read this as 'w' and the comma // zoom would never engage. We match by typed character so the registered ',' shortcut // fires correctly. await act(async () => { document.body.dispatchEvent( new KeyboardEvent('keydown', { key: ',', code: 'KeyW', bubbles: true }) ) }) expect(editor.inputs.keys.has('Comma')).toBe(true) await act(async () => { document.body.dispatchEvent( new KeyboardEvent('keyup', { key: ',', code: 'KeyW', bubbles: true }) ) }) expect(editor.inputs.keys.has('Comma')).toBe(false) }) it('does not fire shortcuts when typing into a textarea', async () => { const { editor } = await renderTldrawComponentWithEditor( (onMount) => , { waitForPatterns: false } ) await act(async () => { editor.focus() }) expect(editor.getCurrentToolId()).toBe('select') const textarea = document.createElement('textarea') document.body.appendChild(textarea) try { await act(async () => { textarea.dispatchEvent( new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', bubbles: true }) ) textarea.dispatchEvent( new KeyboardEvent('keyup', { key: 'd', code: 'KeyD', bubbles: true }) ) }) // Should remain on select tool — the shortcut is filtered out for textarea targets. expect(editor.getCurrentToolId()).toBe('select') } finally { textarea.remove() } }) })