import { act } from '@testing-library/react' import { createShapeId, Editor } from '@tldraw/editor' import { useEffect } from 'react' import { Tldraw } from '../../lib/Tldraw' import { DefaultKeyboardShortcutsDialogContent } from '../../lib/ui/components/KeyboardShortcutsDialog/DefaultKeyboardShortcutsDialogContent' import { TldrawUiMenuContextProvider } from '../../lib/ui/components/primitives/menus/TldrawUiMenuContext' import { useActions } from '../../lib/ui/context/actions' import { getHotkeysStringFromKbd, ParsedKbd, parseKbd, } from '../../lib/ui/hooks/useKeyboardShortcuts' import { useTools } from '../../lib/ui/hooks/useTools' import { renderTldrawComponent, renderTldrawComponentWithEditor, } from '../testutils/renderTldrawComponent' // These kbds are intentionally not registered in the shortcut registry, they're handled by // useNativeClipboardEvents / the upload asset action instead. See SKIP_KBDS in useKeyboardShortcuts. const SKIP_KBDS = ['copy', 'cut', 'paste', 'asset'] // Shortcuts that have a kbd but intentionally don't get their own row in the default keyboard // shortcuts dialog. If you add a shortcut that should be visible to users, add it to // DefaultKeyboardShortcutsDialogContent instead of this list. const NOT_IN_SHORTCUTS_DIALOG = new Set([ // Opening the shortcuts dialog is covered by the a11y "Open keyboard shortcuts" row. 'open-kbd-shortcuts', // Covered by the a11y "Open context menu" row. 'a11y-open-context-menu', // Cursor-targeted variants of zoom in/out that share a label with the rows already shown. 'zoom-in-on-cursor', 'zoom-out-on-cursor', // Rotation is covered by the a11y "Rotate shape" rows. 'rotate-cw', 'rotate-ccw', // Cursor-targeted paste variants of the paste row already shown. 'paste-at-cursor', 'paste-plain-text-at-cursor', // Style-picking shortcuts that aren't surfaced in the dialog. 'select-white-color', 'select-fill-fill', 'select-fill-lined-fill', // Re-selects the last geo tool; covered by the rectangle/ellipse tool rows. 'select-geo-tool', // Page navigation shortcuts without dialog labels. 'change-page-prev', 'change-page-next', // Only rendered when collaboration UI is enabled, which is off in this test. 'open-cursor-chat', ]) interface ShortcutEntry { id: string kbd: string } function serializeParsedKbd(parsed: ParsedKbd): string { const modifiers: string[] = [] if (parsed.meta) modifiers.push('meta') if (parsed.ctrl) modifiers.push('ctrl') if (parsed.alt) modifiers.push('alt') if (parsed.shift) modifiers.push('shift') return [...modifiers, parsed.key].join('+') } export function getKbdKeyCombos(kbd: string): string[] { return parseKbd(getHotkeysStringFromKbd(kbd)).map(serializeParsedKbd) } function ShortcutCapturer({ onCapture }: { onCapture(entries: ShortcutEntry[]): void }) { const actions = useActions() const tools = useTools() useEffect(() => { const entries: ShortcutEntry[] = [] for (const action of Object.values(actions)) { if (!action.kbd || SKIP_KBDS.includes(action.id)) continue entries.push({ id: `action.${action.id}`, kbd: action.kbd }) } for (const tool of Object.values(tools)) { if (!tool.kbd || SKIP_KBDS.includes(tool.id)) continue entries.push({ id: `tool.${tool.id}`, kbd: tool.kbd }) } onCapture(entries) }, [actions, tools, onCapture]) return null } async function getDefaultShortcutEntries() { let captured: ShortcutEntry[] = [] await renderTldrawComponent( (captured = entries)} /> , { waitForPatterns: false } ) return captured } describe('default keyboard shortcuts', () => { it('does not bind the same key combo to more than one action or tool', async () => { const entries = await getDefaultShortcutEntries() // Sanity check: we actually captured the default actions/tools. expect(entries.length).toBeGreaterThan(0) const comboToIds = new Map() for (const entry of entries) { for (const combo of getKbdKeyCombos(entry.kbd)) { const ids = comboToIds.get(combo) ?? [] ids.push(entry.id) comboToIds.set(combo, ids) } } const collisions = [...comboToIds.entries()] .filter(([, ids]) => ids.length > 1) .map(([combo, ids]) => `${combo} -> ${ids.join(', ')}`) expect(collisions).toEqual([]) }) it('lists every shortcut in the keyboard shortcuts dialog (or marks it as intentionally omitted)', async () => { let entries: ShortcutEntry[] = [] const rendered = await renderTldrawComponent( (entries = captured)} /> , { waitForPatterns: false } ) // The raw ids (without the `action.`/`tool.` prefix) rendered as rows in the dialog. const idsInDialog = new Set( [...rendered.container.querySelectorAll('[data-testid^="kbd."]')].map((el) => el.getAttribute('data-testid')!.slice('kbd.'.length) ) ) expect(idsInDialog.size).toBeGreaterThan(0) const missing = entries .map((entry) => entry.id.replace(/^(action|tool)\./, '')) .filter((id) => !idsInDialog.has(id) && !NOT_IN_SHORTCUTS_DIALOG.has(id)) expect(missing).toEqual([]) }) }) async function setupFocusedEditor() { const { editor } = await renderTldrawComponentWithEditor( (onMount) => , { waitForPatterns: false } ) // Shortcuts only register while the editor is focused. act(() => { editor.updateInstanceState({ isFocused: true }) }) return { editor } } function keydown(editor: Editor, init: KeyboardEventInit) { act(() => { editor .getContainerDocument() .body.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ...init })) }) } function keyup(editor: Editor, init: KeyboardEventInit) { act(() => { editor .getContainerDocument() .body.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, ...init })) }) } describe('keyboard shortcuts with a held key', () => { it('fires the plain shortcut on a fresh key press', async () => { const { editor } = await setupFocusedEditor() expect(editor.getInstanceState().isToolLocked).toBe(false) // Plain `q` toggles tool lock. keydown(editor, { key: 'q', code: 'KeyQ' }) expect(editor.getInstanceState().isToolLocked).toBe(true) }) it('does not fall back to the plain shortcut when a modifier is released mid-hold', async () => { const { editor } = await setupFocusedEditor() expect(editor.getInstanceState().isToolLocked).toBe(false) // Press shift+q (copy-hovered-styles), which does not toggle tool lock. keydown(editor, { key: 'q', code: 'KeyQ', shiftKey: true }) expect(editor.getInstanceState().isToolLocked).toBe(false) // Release shift but keep holding q. The auto-repeat keydown events should not start // firing the adjacent plain `q` shortcut (toggle tool lock). keydown(editor, { key: 'q', code: 'KeyQ', shiftKey: false, repeat: true }) keydown(editor, { key: 'q', code: 'KeyQ', shiftKey: false, repeat: true }) expect(editor.getInstanceState().isToolLocked).toBe(false) }) it('fires the plain shortcut again after the held key is released and pressed fresh', async () => { const { editor } = await setupFocusedEditor() keydown(editor, { key: 'q', code: 'KeyQ', shiftKey: true }) keydown(editor, { key: 'q', code: 'KeyQ', shiftKey: false, repeat: true }) expect(editor.getInstanceState().isToolLocked).toBe(false) // Releasing the physical key clears the held-key tracking, so a fresh press works again. keyup(editor, { key: 'q', code: 'KeyQ' }) keydown(editor, { key: 'q', code: 'KeyQ' }) expect(editor.getInstanceState().isToolLocked).toBe(true) }) it('releases the held key even when the keyup lands on a text input', async () => { const { editor } = await setupFocusedEditor() keydown(editor, { key: 'q', code: 'KeyQ', shiftKey: true }) expect(editor.getInstanceState().isToolLocked).toBe(false) // The key is released while focus is inside a text input, so the keyup is otherwise // skipped. It must still clear the held-key tracking. const body = editor.getContainerDocument().body const input = editor.getContainerDocument().createElement('input') body.appendChild(input) act(() => { input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'q', code: 'KeyQ' })) }) body.removeChild(input) // A fresh plain `q` press now works rather than being blocked by a stale entry. keydown(editor, { key: 'q', code: 'KeyQ' }) expect(editor.getInstanceState().isToolLocked).toBe(true) }) // Regression test for #9099: redo (cmd+shift+z) stopped firing after an undo (cmd+z) on // macOS, where the browser swallows the `z` keyup while cmd stays held. The held-key // tracking from #9099 never got cleared, so the stale undo registration blocked the redo // on the same physical `KeyZ`. A fresh keypress must always be free to trigger its match. it('fires redo after undo on the same physical key when the keyup is swallowed (cmd held)', async () => { const { editor } = await setupFocusedEditor() const id = createShapeId() act(() => { editor.markHistoryStoppingPoint() editor.createShape({ id, type: 'geo', x: 0, y: 0 }) }) expect(editor.getCurrentPageShapeIds().has(id)).toBe(true) // cmd+z undoes the shape creation. On macOS the `z` keyup is never delivered while cmd // stays held, so we deliberately don't dispatch it. keydown(editor, { key: 'z', code: 'KeyZ', metaKey: true }) expect(editor.getCurrentPageShapeIds().has(id)).toBe(false) // Adding shift and pressing z again is a fresh keypress (not an auto-repeat), so it must // trigger redo rather than being blocked by the stale undo registration on `KeyZ`. keydown(editor, { key: 'z', code: 'KeyZ', metaKey: true, shiftKey: true }) expect(editor.getCurrentPageShapeIds().has(id)).toBe(true) }) }) // Regression tests for shift+ shortcuts across keyboard layouts. The physical number-row // keys are the same everywhere, but the shifted glyph varies by layout: US shift+2 is '@', British // PC and German '"'. German is the sharp case — it has no dedicated '=' key, so shift+0 produces // '=', which used to alias to the '=' zoom-in shortcut instead of zoom-to-100. Matching keys off // the physical Digit code, so these work regardless of layout and never cross-fire. describe('shifted number-row shortcuts across keyboard layouts', () => { it.each([ ['US / Apple British', '@'], ['British PC / German', '"'], // On AZERTY the number row is shifted, so shift+Digit2 already types '2'. ['AZERTY', '2'], ])('fires zoom-to-selection on shift+2 for a %s layout glyph', async (_layout, key) => { const { editor } = await setupFocusedEditor() const id = createShapeId() act(() => { editor.createShape({ id, type: 'geo', x: 0, y: 0 }) editor.select(id) }) const zoomToSelection = vi.spyOn(editor, 'zoomToSelection').mockImplementation(() => editor) keydown(editor, { key, code: 'Digit2', shiftKey: true }) expect(zoomToSelection).toHaveBeenCalledTimes(1) }) it.each([ ['US / UK', ')'], // German has no '=' key; it sits on shift+0, so this press must still mean zoom-to-100. ['German', '='], ])('zooms to 100%% (never zoom-in) on shift+0 for a %s layout glyph', async (_layout, key) => { const { editor } = await setupFocusedEditor() const resetZoom = vi.spyOn(editor, 'resetZoom').mockImplementation(() => editor) const zoomIn = vi.spyOn(editor, 'zoomIn').mockImplementation(() => editor) keydown(editor, { key, code: 'Digit0', shiftKey: true }) expect(resetZoom).toHaveBeenCalledTimes(1) expect(zoomIn).not.toHaveBeenCalled() }) it('still fires zoom-out for an unshifted number-row symbol (AZERTY types - on Digit6)', async () => { const { editor } = await setupFocusedEditor() const zoomOut = vi.spyOn(editor, 'zoomOut').mockImplementation(() => editor) keydown(editor, { key: '-', code: 'Digit6' }) expect(zoomOut).toHaveBeenCalledTimes(1) }) })