Positional keyboard shortcuts for toolbar (#2409)

Adds positional keyboard shortcuts to the toolbar. Use the 1, 2, 3, 4
etc keys to activate the corresponding tool on the toolbar.

![Kapture 2024-01-05 at 11 52
30](https://github.com/tldraw/tldraw/assets/1489520/82a21436-0f04-465d-9351-3f2768f61f55)


### Change Type
- [x] `minor` — New feature

### Test Plan

1. Use the number keys to activate toolbar items.
- [x] End to end tests

### Release Notes

- You can now use the number keys to select the corresponding tool from
the toolbar

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
alex 2024-01-26 14:49:21 +00:00 committed by GitHub
parent 234ac05d10
commit c3ae981c2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 118 additions and 107 deletions

View file

@ -13,7 +13,7 @@ test.describe('Keyboard Shortcuts', () => {
await setupPage(page) await setupPage(page)
}) })
test('tools', async () => { test('tools', async ({ isMobile }) => {
const geoToolKds = [ const geoToolKds = [
['r', 'rectangle'], ['r', 'rectangle'],
['o', 'ellipse'], ['o', 'ellipse'],
@ -63,6 +63,35 @@ test.describe('Keyboard Shortcuts', () => {
data: { id: tool, source: 'kbd' }, data: { id: tool, source: 'kbd' },
}) })
} }
// make sure that the first dropdown item is rectangle
await page.keyboard.press('r')
const positionalToolKbds = [
['1', 'select'],
['2', 'hand'],
['3', 'draw'],
['4', 'eraser'],
['5', 'arrow'],
['6', 'text'],
]
if (isMobile) {
// on mobile, the last item (first from the dropdown) is 7
positionalToolKbds.push(['7', 'geo-rectangle'])
} else {
// on desktop, the last item (first from the dropdown) is 9. 8 is the image tool which
// we skip here because it opens a browser dialog
positionalToolKbds.push(['9', 'geo-rectangle'])
}
for (const [key, tool] of positionalToolKbds) {
await page.keyboard.press('v') // set back to select
await page.keyboard.press(key)
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'select-tool',
data: { id: tool, source: 'kbd' },
})
}
}) })
}) })

View file

@ -1,7 +1,9 @@
import { GeoShapeGeoStyle, preventDefault, track, useEditor, useValue } from '@tldraw/editor' import { GeoShapeGeoStyle, preventDefault, track, useEditor, useValue } from '@tldraw/editor'
import classNames from 'classnames' import classNames from 'classnames'
import React, { memo } from 'react' import hotkeys from 'hotkeys-js'
import React, { memo, useEffect, useMemo } from 'react'
import { useBreakpoint } from '../../hooks/useBreakpoint' import { useBreakpoint } from '../../hooks/useBreakpoint'
import { areShortcutsDisabled } from '../../hooks/useKeyboardShortcuts'
import { useReadonly } from '../../hooks/useReadonly' import { useReadonly } from '../../hooks/useReadonly'
import { TLUiToolbarItem, useToolbarSchema } from '../../hooks/useToolbarSchema' import { TLUiToolbarItem, useToolbarSchema } from '../../hooks/useToolbarSchema'
import { TLUiToolItem } from '../../hooks/useTools' import { TLUiToolItem } from '../../hooks/useTools'
@ -27,7 +29,6 @@ export const Toolbar = memo(function Toolbar() {
const isReadonly = useReadonly() const isReadonly = useReadonly()
const toolbarItems = useToolbarSchema() const toolbarItems = useToolbarSchema()
const laserTool = toolbarItems.find((item) => item.toolItem.id === 'laser')
const activeToolId = useValue('current tool id', () => editor.getCurrentToolId(), [editor]) const activeToolId = useValue('current tool id', () => editor.getCurrentToolId(), [editor])
@ -37,8 +38,6 @@ export const Toolbar = memo(function Toolbar() {
[editor] [editor]
) )
const showEditingTools = !isReadonly
const getTitle = (item: TLUiToolItem) => const getTitle = (item: TLUiToolItem) =>
item.label ? `${msg(item.label)} ${item.kbd ? kbdStr(item.kbd) : ''}` : '' item.label ? `${msg(item.label)} ${item.kbd ? kbdStr(item.kbd) : ''}` : ''
@ -46,33 +45,11 @@ export const Toolbar = memo(function Toolbar() {
return isActiveTLUiToolItem(item.toolItem, activeToolId, geoState) return isActiveTLUiToolItem(item.toolItem, activeToolId, geoState)
}) })
const { itemsInPanel, itemsInDropdown, dropdownFirstItem } = React.useMemo(() => { const { itemsInPanel, itemsInDropdown } = useToolbarItems()
const itemsInPanel: TLUiToolbarItem[] = [] const dropdownFirstItem = useMemo(() => {
const itemsInDropdown: TLUiToolbarItem[] = [] let dropdownFirstItem = itemsInDropdown.find((item) => item === activeTLUiToolbarItem)
let dropdownFirstItem: TLUiToolbarItem | undefined
const overflowIndex = Math.min(8, 5 + breakpoint) if (!dropdownFirstItem) {
for (let i = 4; i < toolbarItems.length; i++) {
const item = toolbarItems[i]
if (i < overflowIndex) {
// Items below the overflow index will always be in the panel
itemsInPanel.push(item)
} else {
// Items above will be in the dropdown menu unless the item
// is active (or was the most recently selected active item)
if (item === activeTLUiToolbarItem) {
// If the dropdown item is active, make it the dropdownFirstItem
dropdownFirstItem = item
}
// Otherwise, add it to the items in dropdown menu
itemsInDropdown.push(item)
}
}
if (dropdownFirstItem) {
// noop
} else {
// If we don't have a currently active dropdown item, use the most // If we don't have a currently active dropdown item, use the most
// recently active dropdown item as the current dropdown first item. // recently active dropdown item as the current dropdown first item.
@ -98,13 +75,23 @@ export const Toolbar = memo(function Toolbar() {
// set of dropdown items was most recently active // set of dropdown items was most recently active
rMostRecentlyActiveDropdownItem.current = dropdownFirstItem rMostRecentlyActiveDropdownItem.current = dropdownFirstItem
if (itemsInDropdown.length <= 2) { return dropdownFirstItem
itemsInPanel.push(...itemsInDropdown) }, [activeTLUiToolbarItem, itemsInDropdown])
itemsInDropdown.length = 0
}
return { itemsInPanel, itemsInDropdown, dropdownFirstItem } useEffect(() => {
}, [toolbarItems, activeTLUiToolbarItem, breakpoint]) const itemsWithShortcuts = [...itemsInPanel, dropdownFirstItem]
for (let i = 0; i < Math.min(10, itemsWithShortcuts.length); i++) {
const indexKbd = `${i + 1}`.slice(-1)
hotkeys(indexKbd, (event) => {
if (areShortcutsDisabled(editor)) return
preventDefault(event)
itemsWithShortcuts[i].toolItem.onSelect('kbd')
})
}
return () => {
hotkeys.unbind('1,2,3,4,5,6,7,8,9,0')
}
}, [dropdownFirstItem, editor, itemsInPanel])
return ( return (
<div className="tlui-toolbar"> <div className="tlui-toolbar">
@ -129,8 +116,8 @@ export const Toolbar = memo(function Toolbar() {
'tlui-toolbar__tools__mobile': breakpoint < 5, 'tlui-toolbar__tools__mobile': breakpoint < 5,
})} })}
> >
{/* Select / Hand */} {/* Main panel items */}
{toolbarItems.slice(0, 2).map(({ toolItem }) => { {itemsInPanel.map(({ toolItem }) => {
return ( return (
<ToolbarButton <ToolbarButton
key={toolItem.id} key={toolItem.id}
@ -140,34 +127,6 @@ export const Toolbar = memo(function Toolbar() {
/> />
) )
})} })}
{isReadonly && laserTool && (
<ToolbarButton
key={laserTool.toolItem.id}
item={laserTool.toolItem}
title={getTitle(laserTool.toolItem)}
isSelected={isActiveTLUiToolItem(laserTool.toolItem, activeToolId, geoState)}
/>
)}
{showEditingTools && (
<>
{/* Draw / Eraser */}
{toolbarItems.slice(2, 4).map(({ toolItem }) => (
<ToolbarButton
key={toolItem.id}
item={toolItem}
title={getTitle(toolItem)}
isSelected={isActiveTLUiToolItem(toolItem, activeToolId, geoState)}
/>
))}
{/* Everything Else */}
{itemsInPanel.map(({ toolItem }) => (
<ToolbarButton
key={toolItem.id}
item={toolItem}
title={getTitle(toolItem)}
isSelected={isActiveTLUiToolItem(toolItem, activeToolId, geoState)}
/>
))}
{/* Overflowing Shapes */} {/* Overflowing Shapes */}
{itemsInDropdown.length ? ( {itemsInDropdown.length ? (
<> <>
@ -199,8 +158,6 @@ export const Toolbar = memo(function Toolbar() {
</M.Root> </M.Root>
</> </>
) : null} ) : null}
</>
)}
</div> </div>
</div> </div>
{breakpoint < 5 && !isReadonly && ( {breakpoint < 5 && !isReadonly && (
@ -279,3 +236,25 @@ const isActiveTLUiToolItem = (
? activeToolId === 'geo' && geoState === item.meta?.geo ? activeToolId === 'geo' && geoState === item.meta?.geo
: activeToolId === item.id : activeToolId === item.id
} }
export function useToolbarItems() {
const breakpoint = useBreakpoint()
const allToolbarItems = useToolbarSchema()
const isReadonly = useReadonly()
return useMemo(() => {
const visibleItems = allToolbarItems.filter((item) => !isReadonly || item.readonlyOk)
const overflowIndex = Math.min(8, 5 + breakpoint)
const itemsInPanel = visibleItems.slice(0, overflowIndex)
const itemsInDropdown = visibleItems.slice(overflowIndex)
if (itemsInDropdown.length <= 2) {
return {
itemsInPanel: visibleItems,
itemsInDropdown: [],
}
}
return { itemsInPanel, itemsInDropdown }
}, [allToolbarItems, breakpoint, isReadonly])
}

View file

@ -1,6 +1,7 @@
import { TLPointerEventInfo, preventDefault, useEditor, useValue } from '@tldraw/editor' import { Editor, TLPointerEventInfo, preventDefault, useEditor, useValue } from '@tldraw/editor'
import hotkeys from 'hotkeys-js' import hotkeys from 'hotkeys-js'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useToolbarItems } from '../components/Toolbar/Toolbar'
import { useActions } from './useActions' import { useActions } from './useActions'
import { useReadonly } from './useReadonly' import { useReadonly } from './useReadonly'
import { useTools } from './useTools' import { useTools } from './useTools'
@ -22,6 +23,7 @@ export function useKeyboardShortcuts() {
const actions = useActions() const actions = useActions()
const tools = useTools() const tools = useTools()
const isFocused = useValue('is focused', () => editor.getInstanceState().isFocused, [editor]) const isFocused = useValue('is focused', () => editor.getInstanceState().isFocused, [editor])
const { itemsInPanel: toolbarItemsInPanel } = useToolbarItems()
useEffect(() => { useEffect(() => {
if (!isFocused) return if (!isFocused) return
@ -44,16 +46,13 @@ export function useKeyboardShortcuts() {
// Add hotkeys for actions and tools. // Add hotkeys for actions and tools.
// Except those that in SKIP_KBDS! // Except those that in SKIP_KBDS!
const areShortcutsDisabled = () =>
editor.getIsMenuOpen() || editor.getEditingShapeId() !== null || editor.getCrashingError()
for (const action of Object.values(actions)) { for (const action of Object.values(actions)) {
if (!action.kbd) continue if (!action.kbd) continue
if (isReadonly && !action.readonlyOk) continue if (isReadonly && !action.readonlyOk) continue
if (SKIP_KBDS.includes(action.id)) continue if (SKIP_KBDS.includes(action.id)) continue
hot(getHotkeysStringFromKbd(action.kbd), (event) => { hot(getHotkeysStringFromKbd(action.kbd), (event) => {
if (areShortcutsDisabled()) return if (areShortcutsDisabled(editor)) return
preventDefault(event) preventDefault(event)
action.onSelect('kbd') action.onSelect('kbd')
}) })
@ -67,7 +66,7 @@ export function useKeyboardShortcuts() {
if (SKIP_KBDS.includes(tool.id)) continue if (SKIP_KBDS.includes(tool.id)) continue
hot(getHotkeysStringFromKbd(tool.kbd), (event) => { hot(getHotkeysStringFromKbd(tool.kbd), (event) => {
if (areShortcutsDisabled()) return if (areShortcutsDisabled(editor)) return
preventDefault(event) preventDefault(event)
tool.onSelect('kbd') tool.onSelect('kbd')
}) })
@ -75,7 +74,7 @@ export function useKeyboardShortcuts() {
hot(',', (e) => { hot(',', (e) => {
// Skip if shortcuts are disabled // Skip if shortcuts are disabled
if (areShortcutsDisabled()) return if (areShortcutsDisabled(editor)) return
// Don't press again if already pressed // Don't press again if already pressed
if (editor.inputs.keys.has('Comma')) return if (editor.inputs.keys.has('Comma')) return
@ -103,7 +102,7 @@ export function useKeyboardShortcuts() {
}) })
hotUp(',', (e) => { hotUp(',', (e) => {
if (areShortcutsDisabled()) return if (areShortcutsDisabled(editor)) return
if (!editor.inputs.keys.has('Comma')) return if (!editor.inputs.keys.has('Comma')) return
editor.inputs.keys.delete('Comma') editor.inputs.keys.delete('Comma')
@ -128,7 +127,7 @@ export function useKeyboardShortcuts() {
return () => { return () => {
hotkeys.deleteScope(editor.store.id) hotkeys.deleteScope(editor.store.id)
} }
}, [actions, tools, isReadonly, editor, isFocused]) }, [actions, tools, isReadonly, editor, isFocused, toolbarItemsInPanel])
} }
function getHotkeysStringFromKbd(kbd: string) { function getHotkeysStringFromKbd(kbd: string) {
@ -179,3 +178,7 @@ function getKeys(key: string) {
return keys return keys
} }
export function areShortcutsDisabled(editor: Editor) {
return editor.getIsMenuOpen() || editor.getEditingShapeId() !== null || editor.getCrashingError()
}

View file

@ -211,7 +211,7 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
toolsArray.push({ toolsArray.push({
id: 'highlight', id: 'highlight',
label: 'tool.highlight', label: 'tool.highlight',
readonlyOk: true, readonlyOk: false,
icon: 'tool-highlight', icon: 'tool-highlight',
// TODO: pick a better shortcut // TODO: pick a better shortcut
kbd: '!d', kbd: '!d',