From c3ae981c2da791bf6186e68d3bda1157ecae5429 Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 26 Jan 2024 14:49:21 +0000 Subject: [PATCH] Positional keyboard shortcuts for toolbar (#2409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/examples/e2e/tests/test-kbds.spec.ts | 31 +++- .../src/lib/ui/components/Toolbar/Toolbar.tsx | 171 ++++++++---------- .../src/lib/ui/hooks/useKeyboardShortcuts.ts | 21 ++- packages/tldraw/src/lib/ui/hooks/useTools.tsx | 2 +- 4 files changed, 118 insertions(+), 107 deletions(-) diff --git a/apps/examples/e2e/tests/test-kbds.spec.ts b/apps/examples/e2e/tests/test-kbds.spec.ts index 148443637..91986e4d0 100644 --- a/apps/examples/e2e/tests/test-kbds.spec.ts +++ b/apps/examples/e2e/tests/test-kbds.spec.ts @@ -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' }, + }) + } }) }) diff --git a/packages/tldraw/src/lib/ui/components/Toolbar/Toolbar.tsx b/packages/tldraw/src/lib/ui/components/Toolbar/Toolbar.tsx index 8ae7e1f4c..e6b7ed661 100644 --- a/packages/tldraw/src/lib/ui/components/Toolbar/Toolbar.tsx +++ b/packages/tldraw/src/lib/ui/components/Toolbar/Toolbar.tsx @@ -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 (
@@ -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 ( ) })} - {isReadonly && laserTool && ( - - )} - {showEditingTools && ( + {/* Overflowing Shapes */} + {itemsInDropdown.length ? ( <> - {/* Draw / Eraser */} - {toolbarItems.slice(2, 4).map(({ toolItem }) => ( - - ))} - {/* Everything Else */} - {itemsInPanel.map(({ toolItem }) => ( - - ))} - {/* Overflowing Shapes */} - {itemsInDropdown.length ? ( - <> - {/* Last selected (or first) item from the overflow */} - + {/* The dropdown to select everything else */} + + +
{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]) +} diff --git a/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts b/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts index 314281d66..5fd564de7 100644 --- a/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts +++ b/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts @@ -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() +} diff --git a/packages/tldraw/src/lib/ui/hooks/useTools.tsx b/packages/tldraw/src/lib/ui/hooks/useTools.tsx index 75433d6ec..9817a46de 100644 --- a/packages/tldraw/src/lib/ui/hooks/useTools.tsx +++ b/packages/tldraw/src/lib/ui/hooks/useTools.tsx @@ -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',