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)
|
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' },
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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,67 +127,37 @@ export const Toolbar = memo(function Toolbar() {
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{isReadonly && laserTool && (
|
{/* Overflowing Shapes */}
|
||||||
<ToolbarButton
|
{itemsInDropdown.length ? (
|
||||||
key={laserTool.toolItem.id}
|
|
||||||
item={laserTool.toolItem}
|
|
||||||
title={getTitle(laserTool.toolItem)}
|
|
||||||
isSelected={isActiveTLUiToolItem(laserTool.toolItem, activeToolId, geoState)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showEditingTools && (
|
|
||||||
<>
|
<>
|
||||||
{/* Draw / Eraser */}
|
{/* Last selected (or first) item from the overflow */}
|
||||||
{toolbarItems.slice(2, 4).map(({ toolItem }) => (
|
<ToolbarButton
|
||||||
<ToolbarButton
|
key={dropdownFirstItem.toolItem.id}
|
||||||
key={toolItem.id}
|
item={dropdownFirstItem.toolItem}
|
||||||
item={toolItem}
|
title={getTitle(dropdownFirstItem.toolItem)}
|
||||||
title={getTitle(toolItem)}
|
isSelected={isActiveTLUiToolItem(
|
||||||
isSelected={isActiveTLUiToolItem(toolItem, activeToolId, geoState)}
|
dropdownFirstItem.toolItem,
|
||||||
/>
|
activeToolId,
|
||||||
))}
|
geoState
|
||||||
{/* Everything Else */}
|
)}
|
||||||
{itemsInPanel.map(({ toolItem }) => (
|
/>
|
||||||
<ToolbarButton
|
{/* The dropdown to select everything else */}
|
||||||
key={toolItem.id}
|
<M.Root id="toolbar overflow" modal={false}>
|
||||||
item={toolItem}
|
<M.Trigger>
|
||||||
title={getTitle(toolItem)}
|
<Button
|
||||||
isSelected={isActiveTLUiToolItem(toolItem, activeToolId, geoState)}
|
className="tlui-toolbar__overflow"
|
||||||
/>
|
icon="chevron-up"
|
||||||
))}
|
type="tool"
|
||||||
{/* Overflowing Shapes */}
|
data-testid="tools.more"
|
||||||
{itemsInDropdown.length ? (
|
title={msg('tool-panel.more')}
|
||||||
<>
|
|
||||||
{/* 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.Trigger>
|
||||||
<M.Root id="toolbar overflow" modal={false}>
|
<M.Content side="top" align="center">
|
||||||
<M.Trigger>
|
<OverflowToolsContent toolbarItems={itemsInDropdown} />
|
||||||
<Button
|
</M.Content>
|
||||||
className="tlui-toolbar__overflow"
|
</M.Root>
|
||||||
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}
|
|
||||||
</>
|
</>
|
||||||
)}
|
) : 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])
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
|
Loading…
Reference in a new issue