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:
parent
234ac05d10
commit
c3ae981c2d
4 changed files with 118 additions and 107 deletions
|
@ -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' },
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in a new issue