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)
})
test('tools', async () => {
test('tools', async ({ isMobile }) => {
const geoToolKds = [
['r', 'rectangle'],
['o', 'ellipse'],
@ -63,6 +63,35 @@ test.describe('Keyboard Shortcuts', () => {
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 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 { areShortcutsDisabled } from '../../hooks/useKeyboardShortcuts'
import { useReadonly } from '../../hooks/useReadonly'
import { TLUiToolbarItem, useToolbarSchema } from '../../hooks/useToolbarSchema'
import { TLUiToolItem } from '../../hooks/useTools'
@ -27,7 +29,6 @@ export const Toolbar = memo(function Toolbar() {
const isReadonly = useReadonly()
const toolbarItems = useToolbarSchema()
const laserTool = toolbarItems.find((item) => item.toolItem.id === 'laser')
const activeToolId = useValue('current tool id', () => editor.getCurrentToolId(), [editor])
@ -37,8 +38,6 @@ export const Toolbar = memo(function Toolbar() {
[editor]
)
const showEditingTools = !isReadonly
const getTitle = (item: TLUiToolItem) =>
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)
})
const { itemsInPanel, itemsInDropdown, dropdownFirstItem } = React.useMemo(() => {
const itemsInPanel: TLUiToolbarItem[] = []
const itemsInDropdown: TLUiToolbarItem[] = []
let dropdownFirstItem: TLUiToolbarItem | undefined
const { itemsInPanel, itemsInDropdown } = useToolbarItems()
const dropdownFirstItem = useMemo(() => {
let dropdownFirstItem = itemsInDropdown.find((item) => item === activeTLUiToolbarItem)
const overflowIndex = Math.min(8, 5 + breakpoint)
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 (!dropdownFirstItem) {
// If we don't have a currently active dropdown item, use the most
// 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
rMostRecentlyActiveDropdownItem.current = dropdownFirstItem
if (itemsInDropdown.length <= 2) {
itemsInPanel.push(...itemsInDropdown)
itemsInDropdown.length = 0
}
return dropdownFirstItem
}, [activeTLUiToolbarItem, itemsInDropdown])
return { itemsInPanel, itemsInDropdown, dropdownFirstItem }
}, [toolbarItems, activeTLUiToolbarItem, breakpoint])
useEffect(() => {
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 (
<div className="tlui-toolbar">
@ -129,8 +116,8 @@ export const Toolbar = memo(function Toolbar() {
'tlui-toolbar__tools__mobile': breakpoint < 5,
})}
>
{/* Select / Hand */}
{toolbarItems.slice(0, 2).map(({ toolItem }) => {
{/* Main panel items */}
{itemsInPanel.map(({ toolItem }) => {
return (
<ToolbarButton
key={toolItem.id}
@ -140,67 +127,37 @@ 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 && (
{/* Overflowing Shapes */}
{itemsInDropdown.length ? (
<>
{/* 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 */}
{itemsInDropdown.length ? (
<>
{/* Last selected (or first) item from the overflow */}
<ToolbarButton
key={dropdownFirstItem.toolItem.id}
item={dropdownFirstItem.toolItem}
title={getTitle(dropdownFirstItem.toolItem)}
isSelected={isActiveTLUiToolItem(
dropdownFirstItem.toolItem,
activeToolId,
geoState
)}
{/* Last selected (or first) item from the overflow */}
<ToolbarButton
key={dropdownFirstItem.toolItem.id}
item={dropdownFirstItem.toolItem}
title={getTitle(dropdownFirstItem.toolItem)}
isSelected={isActiveTLUiToolItem(
dropdownFirstItem.toolItem,
activeToolId,
geoState
)}
/>
{/* The dropdown to select everything else */}
<M.Root id="toolbar overflow" modal={false}>
<M.Trigger>
<Button
className="tlui-toolbar__overflow"
icon="chevron-up"
type="tool"
data-testid="tools.more"
title={msg('tool-panel.more')}
/>
{/* The dropdown to select everything else */}
<M.Root id="toolbar overflow" modal={false}>
<M.Trigger>
<Button
className="tlui-toolbar__overflow"
icon="chevron-up"
type="tool"
data-testid="tools.more"
title={msg('tool-panel.more')}
/>
</M.Trigger>
<M.Content side="top" align="center">
<OverflowToolsContent toolbarItems={itemsInDropdown} />
</M.Content>
</M.Root>
</>
) : null}
</M.Trigger>
<M.Content side="top" align="center">
<OverflowToolsContent toolbarItems={itemsInDropdown} />
</M.Content>
</M.Root>
</>
)}
) : null}
</div>
</div>
{breakpoint < 5 && !isReadonly && (
@ -279,3 +236,25 @@ const isActiveTLUiToolItem = (
? activeToolId === 'geo' && geoState === item.meta?.geo
: 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 { useEffect } from 'react'
import { useToolbarItems } from '../components/Toolbar/Toolbar'
import { useActions } from './useActions'
import { useReadonly } from './useReadonly'
import { useTools } from './useTools'
@ -22,6 +23,7 @@ export function useKeyboardShortcuts() {
const actions = useActions()
const tools = useTools()
const isFocused = useValue('is focused', () => editor.getInstanceState().isFocused, [editor])
const { itemsInPanel: toolbarItemsInPanel } = useToolbarItems()
useEffect(() => {
if (!isFocused) return
@ -44,16 +46,13 @@ export function useKeyboardShortcuts() {
// Add hotkeys for actions and tools.
// Except those that in SKIP_KBDS!
const areShortcutsDisabled = () =>
editor.getIsMenuOpen() || editor.getEditingShapeId() !== null || editor.getCrashingError()
for (const action of Object.values(actions)) {
if (!action.kbd) continue
if (isReadonly && !action.readonlyOk) continue
if (SKIP_KBDS.includes(action.id)) continue
hot(getHotkeysStringFromKbd(action.kbd), (event) => {
if (areShortcutsDisabled()) return
if (areShortcutsDisabled(editor)) return
preventDefault(event)
action.onSelect('kbd')
})
@ -67,7 +66,7 @@ export function useKeyboardShortcuts() {
if (SKIP_KBDS.includes(tool.id)) continue
hot(getHotkeysStringFromKbd(tool.kbd), (event) => {
if (areShortcutsDisabled()) return
if (areShortcutsDisabled(editor)) return
preventDefault(event)
tool.onSelect('kbd')
})
@ -75,7 +74,7 @@ export function useKeyboardShortcuts() {
hot(',', (e) => {
// Skip if shortcuts are disabled
if (areShortcutsDisabled()) return
if (areShortcutsDisabled(editor)) return
// Don't press again if already pressed
if (editor.inputs.keys.has('Comma')) return
@ -103,7 +102,7 @@ export function useKeyboardShortcuts() {
})
hotUp(',', (e) => {
if (areShortcutsDisabled()) return
if (areShortcutsDisabled(editor)) return
if (!editor.inputs.keys.has('Comma')) return
editor.inputs.keys.delete('Comma')
@ -128,7 +127,7 @@ export function useKeyboardShortcuts() {
return () => {
hotkeys.deleteScope(editor.store.id)
}
}, [actions, tools, isReadonly, editor, isFocused])
}, [actions, tools, isReadonly, editor, isFocused, toolbarItemsInPanel])
}
function getHotkeysStringFromKbd(kbd: string) {
@ -179,3 +178,7 @@ function getKeys(key: string) {
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({
id: 'highlight',
label: 'tool.highlight',
readonlyOk: true,
readonlyOk: false,
icon: 'tool-highlight',
// TODO: pick a better shortcut
kbd: '!d',