import { act, screen } from '@testing-library/react' import { createShapeId, Editor, TLShapeId } 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 { useDialogs } from '../../lib/ui/context/dialogs' 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([]) }) }) describe('keyboard shortcuts dialog with shortcuts turned off', () => { // Opens the real dialog through the action, so the header/body primitives get the Radix // dialog context they need. The open dialog is registered in the global `tlmenus`, which // survives unmount, so each test closes it again to avoid disabling later tests' shortcuts. let clearDialogs: (() => void) | null = null function OpenShortcutsDialog() { const dialogs = useDialogs() const actions = useActions() useEffect(() => { clearDialogs = dialogs.clearDialogs actions['open-kbd-shortcuts'].onSelect('kbd') }, [dialogs, actions]) return null } afterEach(() => { act(() => clearDialogs?.()) clearDialogs = null }) async function renderDialog() { const { editor, rendered } = await renderTldrawComponentWithEditor( (onMount) => ( ), { waitForPatterns: false } ) await rendered.findByTestId('kbd.select') return { editor, rendered } } it('shows no notice while shortcuts are enabled', async () => { const { rendered } = await renderDialog() expect(rendered.queryByTestId('kbd.disabled-notice')).toBeNull() }) it('shows a notice that shortcuts are turned off, and hides it again when they are re-enabled', async () => { const { editor, rendered } = await renderDialog() act(() => { editor.user.updateUserPreferences({ areKeyboardShortcutsEnabled: false }) }) expect(rendered.getByTestId('kbd.disabled-notice')).toBeTruthy() // The shortcut rows still render so the user can see what the preference turns back on. expect(rendered.getByTestId('kbd.select')).toBeTruthy() act(() => { editor.user.updateUserPreferences({ areKeyboardShortcutsEnabled: true }) }) expect(rendered.queryByTestId('kbd.disabled-notice')).toBeNull() }) }) describe('keyboard shortcuts dialog overridden with null', () => { it('does not open a dialog', async () => { let dialogs: ReturnType | null = null function OpenShortcutsDialog() { const actions = useActions() dialogs = useDialogs() useEffect(() => { actions['open-kbd-shortcuts'].onSelect('kbd') }, [actions]) return null } const { rendered } = await renderTldrawComponentWithEditor( (onMount) => ( ), { waitForPatterns: false } ) expect(dialogs!.dialogs.get()).toEqual([]) expect(rendered.queryByTestId('kbd.select')).toBeNull() }) }) 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 } } async function setupEditorWithKbd(kbd: string) { const onSelect = vi.fn() const { editor } = await renderTldrawComponentWithEditor( (onMount) => ( ({ ...actions, 'test-kbd': { id: 'test-kbd', label: 'Test kbd', kbd, onSelect }, }), }} /> ), { waitForPatterns: false } ) act(() => { editor.updateInstanceState({ isFocused: true }) }) return { editor, onSelect } } // The UI's shortcut listeners sit on the body, the editor's own on the container. Pass a target // inside the container to reach both. function keydown(editor: Editor, init: KeyboardEventInit, target?: Element) { dispatchKey(editor, 'keydown', init, target) } function keyup(editor: Editor, init: KeyboardEventInit, target?: Element) { dispatchKey(editor, 'keyup', init, target) } function dispatchKey( editor: Editor, type: 'keydown' | 'keyup', init: KeyboardEventInit, target: Element = editor.getContainerDocument().body ) { act(() => { target.dispatchEvent(new KeyboardEvent(type, { 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) }) it('leaves the zoom tool when z is released', async () => { const { editor } = await setupFocusedEditor() editor.setCurrentTool('draw') keydown(editor, { key: 'z', code: 'KeyZ' }, editor.getContainer()) expect(editor.getPath()).toBe('zoom.idle') keyup(editor, { key: 'z', code: 'KeyZ' }, editor.getContainer()) expect(editor.getPath()).toBe('draw.idle') }) // The shortcut fires whatever holds focus; the editor's key events don't follow it there it('leaves the zoom tool when z is released after a toolbar click', async () => { const { editor } = await setupFocusedEditor() const drawButton = screen.getByTestId('tools.draw') act(() => { drawButton.focus() drawButton.click() }) expect(editor.getCurrentToolId()).toBe('draw') expect(editor.getContainerDocument().activeElement).toBe(drawButton) keydown(editor, { key: 'z', code: 'KeyZ' }, drawButton) expect(editor.getPath()).toBe('zoom.idle') keyup(editor, { key: 'z', code: 'KeyZ' }, drawButton) expect(editor.getPath()).toBe('draw.idle') }) }) // 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) }) }) const NO_MODS = { shift: false, alt: false, ctrl: false, meta: false } describe('parseKbd literal plus key', () => { it('parses a doubled trailing + as the + key', () => { expect(parseKbd('cmd++')).toEqual([{ ...NO_MODS, meta: true, key: '+' }]) expect(parseKbd('+')).toEqual([{ ...NO_MODS, key: '+' }]) expect(parseKbd('cmd++,ctrl++,+')).toEqual([ { ...NO_MODS, meta: true, key: '+' }, { ...NO_MODS, ctrl: true, key: '+' }, { ...NO_MODS, key: '+' }, ]) }) it('tolerates extra plus signs before the literal plus key', () => { expect(parseKbd('cmd+++')).toEqual([{ ...NO_MODS, meta: true, key: '+' }]) expect(parseKbd('++')).toEqual([{ ...NO_MODS, key: '+' }]) }) it('parses an atomic [[+]] token as the + key', () => { expect(parseKbd('cmd+[[+]]')).toEqual([{ ...NO_MODS, meta: true, key: '+' }]) expect(parseKbd('[[+]]')).toEqual([{ ...NO_MODS, key: '+' }]) }) it('rejects an atomic token without a + separator before it', () => { expect(parseKbd('cmd[[+]]')).toEqual([]) expect(parseKbd('cmd+foo[[+]]')).toEqual([]) expect(parseKbd('[[foo]]++')).toEqual([]) expect(parseKbd('[[]]')).toEqual([]) }) it('keeps multi-character atomic tokens display-only', () => { expect(parseKbd('[[Tab]]')).toEqual([]) expect(parseKbd('cmd+[[Enter]]')).toEqual([]) }) it('still drops a single trailing + so legacy markers cannot become a plus binding', () => { // getHotkeysStringFromKbd treats ? as the legacy alt marker and strips it, leaving 'alt+shift+'. expect(getHotkeysStringFromKbd('shift+?')).toBe('alt+shift+') expect(parseKbd('alt+shift+')).toEqual([]) expect(parseKbd(getHotkeysStringFromKbd('shift+?'))).toEqual([]) }) }) describe('parseKbd canonicalizes shifted glyphs', () => { it('maps a shifted glyph to its US base key so it matches the normalized event key', () => { expect(parseKbd('shift+:')).toEqual([{ ...NO_MODS, shift: true, key: ';' }]) expect(parseKbd('shift+^')).toEqual([{ ...NO_MODS, shift: true, key: '6' }]) expect(parseKbd('shift+cmd+^')).toEqual([{ ...NO_MODS, shift: true, meta: true, key: '6' }]) }) it('treats the shifted and unshifted spellings as the same binding', () => { expect(parseKbd('shift+:')).toEqual(parseKbd('shift+;')) expect(parseKbd('shift+{')).toEqual(parseKbd('shift+[')) }) it('leaves an unshifted glyph alone', () => { expect(parseKbd(':')).toEqual([{ ...NO_MODS, key: ':' }]) }) it('canonicalizes a shifted literal plus to =', () => { expect(parseKbd('shift+cmd++')).toEqual([{ ...NO_MODS, shift: true, meta: true, key: '=' }]) expect(parseKbd('cmd+alt+shift++')).toEqual([ { ...NO_MODS, shift: true, meta: true, alt: true, key: '=' }, ]) }) }) describe('literal plus key shortcuts', () => { it.each([ ['cmd++', { key: '+', code: 'BracketRight', metaKey: true }], ['cmd+[[+]]', { key: '+', code: 'BracketRight', metaKey: true }], ['+', { key: '+', code: 'BracketRight' }], ['shift+cmd++', { key: '+', code: 'Equal', metaKey: true, shiftKey: true }], ['cmd+alt+shift++', { key: '+', code: 'Equal', metaKey: true, altKey: true, shiftKey: true }], ['shift+[[+]]', { key: '+', code: 'Equal', shiftKey: true }], ])('fires %s on the + key', async (kbd, init) => { const { editor, onSelect } = await setupEditorWithKbd(kbd) keydown(editor, init) expect(onSelect).toHaveBeenCalledTimes(1) }) it('does not fire shift++ on an unshifted + press', async () => { const { editor, onSelect } = await setupEditorWithKbd('shift++') keydown(editor, { key: '+', code: 'BracketRight' }) expect(onSelect).not.toHaveBeenCalled() }) it('does not fire cmd++ on cmd+=', async () => { const { editor, onSelect } = await setupEditorWithKbd('cmd++') keydown(editor, { key: '=', code: 'Equal', metaKey: true }) expect(onSelect).not.toHaveBeenCalled() }) }) describe('shifted glyph shortcuts', () => { it('fires shift+: when the browser reports : with shift held', async () => { const { editor, onSelect } = await setupEditorWithKbd('shift+:') keydown(editor, { key: ':', code: 'Semicolon', shiftKey: true }) expect(onSelect).toHaveBeenCalledTimes(1) }) it.each([ ['Digit6 code', 'Digit6'], ['non-digit code', 'Equal'], ])('fires shift+^ when the browser reports ^ with shift held (%s)', async (_name, code) => { const { editor, onSelect } = await setupEditorWithKbd('shift+^') keydown(editor, { key: '^', code, shiftKey: true }) expect(onSelect).toHaveBeenCalledTimes(1) }) // Shortcuts bind the glyph the browser reports, so on hardware where the keycap differs from // the shifted output (#10067) the emitted glyph is the one to bind: AZERTY shift+`:` (on the // Period position) emits `/`, JIS shift+`^` (on Equal) emits `~`. it('fires shift+/ on a real AZERTY shift+: key press', async () => { const { editor, onSelect } = await setupEditorWithKbd('shift+/') keydown(editor, { key: '/', code: 'Period', shiftKey: true }) expect(onSelect).toHaveBeenCalledTimes(1) }) it('fires shift+~ on a real JIS shift+^ key press', async () => { const { editor, onSelect } = await setupEditorWithKbd('shift+~') keydown(editor, { key: '~', code: 'Equal', shiftKey: true }) expect(onSelect).toHaveBeenCalledTimes(1) }) it('keeps the shifted number row positional: shift+& does not fire on German Digit6, shift+6 does', async () => { const amp = await setupEditorWithKbd('shift+&') keydown(amp.editor, { key: '&', code: 'Digit6', shiftKey: true }) expect(amp.onSelect).not.toHaveBeenCalled() const six = await setupEditorWithKbd('shift+6') keydown(six.editor, { key: '&', code: 'Digit6', shiftKey: true }) expect(six.onSelect).toHaveBeenCalledTimes(1) }) it('still fires shift+/ on a US ? event', async () => { const { editor, onSelect } = await setupEditorWithKbd('shift+/') keydown(editor, { key: '?', code: 'Slash', shiftKey: true }) expect(onSelect).toHaveBeenCalledTimes(1) }) it('still fires shift+1 on a US ! event', async () => { const { editor, onSelect } = await setupEditorWithKbd('shift+1') keydown(editor, { key: '!', code: 'Digit1', shiftKey: true }) expect(onSelect).toHaveBeenCalledTimes(1) }) }) // Regression test for #10422: frame-selection was the only cmd shortcut without a ctrl twin, // so Ctrl+Alt+G did nothing on Windows and Linux even though the shortcuts dialog listed it. describe('frame selection shortcut', () => { function createArrowBoundTo(editor: Editor, arrow: TLShapeId, target: TLShapeId) { editor.createShapes([{ id: arrow, type: 'arrow', x: 0, y: 0 }]) editor.createBindings([ { fromId: arrow, toId: target, type: 'arrow', props: { terminal: 'end', normalizedAnchor: { x: 0.5, y: 0.5 }, isExact: false, isPrecise: false, }, }, ]) } it.each([ ['cmd+alt+g (macOS)', { metaKey: true }], ['ctrl+alt+g (Windows / Linux)', { ctrlKey: true }], ])('wraps the selection in a frame on %s', async (_label, modifier) => { const { editor } = await setupFocusedEditor() const a = createShapeId() const b = createShapeId() act(() => { editor.createShapes([ { id: a, type: 'geo', x: 0, y: 0 }, { id: b, type: 'geo', x: 200, y: 200 }, ]) editor.select(a, b) }) keydown(editor, { key: 'g', code: 'KeyG', altKey: true, ...modifier }) const frame = editor.getCurrentPageShapes().find((s) => editor.isShapeOfType(s, 'frame')) expect(frame).toBeDefined() expect(editor.getShape(a)?.parentId).toBe(frame!.id) expect(editor.getShape(b)?.parentId).toBe(frame!.id) }) it('wraps a single selected shape in a frame', async () => { const { editor } = await setupFocusedEditor() const a = createShapeId() act(() => { editor.createShapes([{ id: a, type: 'geo', x: 0, y: 0 }]) editor.select(a) }) keydown(editor, { key: 'g', code: 'KeyG', altKey: true, metaKey: true }) const frame = editor.getCurrentPageShapes().find((s) => editor.isShapeOfType(s, 'frame')) expect(frame).toBeDefined() expect(editor.getShape(a)?.parentId).toBe(frame!.id) expect(editor.getSelectedShapeIds()).toEqual([frame!.id]) }) it('frames a single shape with no height, like a horizontal arrow', async () => { const { editor } = await setupFocusedEditor() const arrow = createShapeId() act(() => { editor.createShapes([ { id: arrow, type: 'arrow', x: 0, y: 0, props: { start: { x: 0, y: 0 }, end: { x: 100, y: 0 } }, }, ]) editor.select(arrow) }) keydown(editor, { key: 'g', code: 'KeyG', altKey: true, metaKey: true }) const frame = editor.getCurrentPageShapes().find((s) => editor.isShapeOfType(s, 'frame')) expect(frame).toBeDefined() expect(editor.getShape(arrow)?.parentId).toBe(frame!.id) }) it('removes the frame when a single frame is selected', async () => { const { editor } = await setupFocusedEditor() const frameId = createShapeId() const a = createShapeId() act(() => { editor.createShapes([ { id: frameId, type: 'frame', x: 0, y: 0, props: { w: 200, h: 200 } }, { id: a, type: 'geo', parentId: frameId, x: 10, y: 10 }, ]) editor.select(frameId) }) keydown(editor, { key: 'g', code: 'KeyG', altKey: true, metaKey: true }) expect(editor.getShape(frameId)).toBeUndefined() expect(editor.getShape(a)?.parentId).toBe(editor.getCurrentPageId()) }) it('leaves locked shapes out of the frame and in place', async () => { const { editor } = await setupFocusedEditor() const locked = createShapeId() const a = createShapeId() act(() => { editor.createShapes([ { id: locked, type: 'geo', x: 0, y: 0, isLocked: true }, { id: a, type: 'geo', x: 200, y: 200 }, ]) editor.select(locked, a) }) keydown(editor, { key: 'g', code: 'KeyG', altKey: true, metaKey: true }) const frame = editor.getCurrentPageShapes().find((s) => editor.isShapeOfType(s, 'frame')) expect(frame).toBeDefined() expect(editor.getShape(a)?.parentId).toBe(frame!.id) expect(editor.getShape(locked)).toMatchObject({ parentId: editor.getCurrentPageId(), x: 0, y: 0, }) }) it('does nothing for a lone arrow bound to a shape outside the selection', async () => { const { editor } = await setupFocusedEditor() const target = createShapeId() const arrow = createShapeId() act(() => { editor.createShapes([{ id: target, type: 'geo', x: 400, y: 400 }]) createArrowBoundTo(editor, arrow, target) editor.select(arrow) }) keydown(editor, { key: 'g', code: 'KeyG', altKey: true, metaKey: true }) expect(editor.getCurrentPageShapes().some((s) => editor.isShapeOfType(s, 'frame'))).toBe(false) expect(editor.getShape(arrow)?.parentId).toBe(editor.getCurrentPageId()) }) it('does nothing for an arrow bound to a selected locked shape', async () => { const { editor } = await setupFocusedEditor() const target = createShapeId() const arrow = createShapeId() act(() => { editor.createShapes([{ id: target, type: 'geo', x: 400, y: 400, isLocked: true }]) createArrowBoundTo(editor, arrow, target) editor.select(target, arrow) }) keydown(editor, { key: 'g', code: 'KeyG', altKey: true, metaKey: true }) expect(editor.getCurrentPageShapes().some((s) => editor.isShapeOfType(s, 'frame'))).toBe(false) expect(editor.getShape(arrow)?.parentId).toBe(editor.getCurrentPageId()) }) it('frames an arrow together with the shape it is bound to', async () => { const { editor } = await setupFocusedEditor() const target = createShapeId() const arrow = createShapeId() act(() => { editor.createShapes([{ id: target, type: 'geo', x: 400, y: 400 }]) createArrowBoundTo(editor, arrow, target) editor.select(arrow, target) }) keydown(editor, { key: 'g', code: 'KeyG', altKey: true, metaKey: true }) const frame = editor.getCurrentPageShapes().find((s) => editor.isShapeOfType(s, 'frame')) expect(frame).toBeDefined() expect(editor.getShape(arrow)?.parentId).toBe(frame!.id) expect(editor.getShape(target)?.parentId).toBe(frame!.id) }) }) describe('scale selection shortcuts', () => { it.each([ ['enlarge', '=', 'Equal', 1.1], ['shrink', '-', 'Minus', 1 / 1.1], ] as const)( '%s scales every shape about the original selection center and undoes together', async (_label, key, code, factor) => { const { editor } = await setupFocusedEditor() const a = createShapeId() const b = createShapeId() act(() => { editor.createShapes([ { id: a, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }, { id: b, type: 'geo', x: 200, y: 200, props: { w: 100, h: 100 } }, ]) editor.select(a, b) }) const originalShapes = editor.getSelectedShapes() keydown(editor, { key, code, metaKey: true, altKey: true, shiftKey: true }) expect([editor.getShape(a), editor.getShape(b)]).toMatchObject([ { x: expect.closeTo(150 - 150 * factor), y: expect.closeTo(150 - 150 * factor), props: { w: expect.closeTo(100 * factor), h: expect.closeTo(100 * factor) }, }, { x: expect.closeTo(150 + 50 * factor), y: expect.closeTo(150 + 50 * factor), props: { w: expect.closeTo(100 * factor), h: expect.closeTo(100 * factor) }, }, ]) act(() => { editor.undo() }) expect(editor.getSelectedShapes()).toEqual(originalShapes) } ) })