diff --git a/src/Toolbar.tsx b/src/Toolbar.tsx index 0d18a45bb..992fb1753 100644 --- a/src/Toolbar.tsx +++ b/src/Toolbar.tsx @@ -10,6 +10,7 @@ import { useInteractionMap } from 'hooks/useInteractionMap' import { ActionButtonDropdown } from 'components/ActionButtonDropdown' import { useHotkeys } from 'react-hotkeys-hook' import Tooltip from 'components/Tooltip' +import { KEYBINDING_CATEGORIES } from 'lib/constants' export function Toolbar({ className = '', @@ -40,7 +41,8 @@ export function Toolbar({ guard: () => !shouldDisableModelingActions && state.matches('idle'), }, ], - [shouldDisableModelingActions, commandBarSend, state] + [shouldDisableModelingActions, commandBarSend, state], + KEYBINDING_CATEGORIES.MODELING ) const iconClassName = 'group-disabled:text-chalkboard-50 group-enabled:group-hover:!text-primary dark:group-enabled:group-hover:!text-inherit group-pressed:!text-chalkboard-10 group-ui-open:!text-chalkboard-10 dark:group-ui-open:!text-chalkboard-10' diff --git a/src/components/CommandBar/CommandBar.tsx b/src/components/CommandBar/CommandBar.tsx index ccb81dc17..97ddec7a4 100644 --- a/src/components/CommandBar/CommandBar.tsx +++ b/src/components/CommandBar/CommandBar.tsx @@ -5,9 +5,8 @@ import CommandBarArgument from './CommandBarArgument' import CommandComboBox from '../CommandComboBox' import CommandBarReview from './CommandBarReview' import { useLocation } from 'react-router-dom' -import { InteractionMapItem } from 'machines/interactionMapMachine' import { useInteractionMap } from 'hooks/useInteractionMap' -import useHotkeyWrapper from 'lib/hotkeyWrapper' +import { KEYBINDING_CATEGORIES } from 'lib/constants' export const CommandBar = () => { const { pathname } = useLocation() @@ -49,7 +48,7 @@ export const CommandBar = () => { }, ], [commandBarState, commandBarSend], - 'Command Bar' + KEYBINDING_CATEGORIES.COMMAND_BAR ) function stepBack() { diff --git a/src/components/InteractionMapMachineProvider.tsx b/src/components/InteractionMapMachineProvider.tsx index 17c304eff..a08027015 100644 --- a/src/components/InteractionMapMachineProvider.tsx +++ b/src/components/InteractionMapMachineProvider.tsx @@ -1,6 +1,12 @@ import { useMachine } from '@xstate/react' import { INTERACTION_MAP_SEPARATOR } from 'lib/constants' -import { isModifierKey, mapKey, sortKeys } from 'lib/keyboard' +import { + isModifierKey, + mapKey, + mouseButtonToName, + resolveInteractionEvent, + sortKeys, +} from 'lib/keyboard' import { MouseButtonName, interactionMapMachine, @@ -91,33 +97,18 @@ export function InteractionMapMachineProvider({ }, services: { 'Resolve hotkey by prefix': (context, event) => { - // First determine if we have a mouse or keyboard event - const action = - 'key' in event.data - ? mapKey(event.data.code) - : mouseButtonToName(event.data.button) - - console.log('action', action) + const resolvedInteraction = resolveInteractionEvent(event.data) // if the key is already a modifier key, skip everything else and reject - if (isModifierKey(action)) { + if (resolvedInteraction.isModifier) { // We return an empty string so that we don't clear the currentSequence return Promise.reject('') } - const modifiers = [ - event.data.ctrlKey && 'ctrl', - event.data.shiftKey && 'shift', - event.data.altKey && 'alt', - event.data.metaKey && 'meta', - ].filter((item) => item !== false) as string[] - const step = [action, ...modifiers] - .sort(sortKeys) - .join(INTERACTION_MAP_SEPARATOR) - // Find all the sequences that start with the current sequence const searchString = - (context.currentSequence ? context.currentSequence + ' ' : '') + step + (context.currentSequence ? context.currentSequence + ' ' : '') + + resolvedInteraction.asString const matches = context.interactionMap.filter((item) => item.sequence.startsWith(searchString) @@ -141,7 +132,7 @@ export function InteractionMapMachineProvider({ // We have a prefix match. // Reject the promise and return the step // so we can add it to currentSequence - return Promise.reject(step) + return Promise.reject(resolvedInteraction.asString) } // Resolve to just one exact match @@ -211,16 +202,3 @@ export function InteractionMapMachineProvider({ ) } - -function mouseButtonToName(button: MouseEvent['button']): MouseButtonName { - switch (button) { - case 0: - return 'LeftButton' - case 1: - return 'MiddleButton' - case 2: - return 'RightButton' - default: - return 'LeftButton' - } -} diff --git a/src/components/Settings/AllKeybindingsFields.tsx b/src/components/Settings/AllKeybindingsFields.tsx index 927a65098..444236ca1 100644 --- a/src/components/Settings/AllKeybindingsFields.tsx +++ b/src/components/Settings/AllKeybindingsFields.tsx @@ -1,4 +1,14 @@ +import { ActionIcon } from 'components/ActionIcon' import { useInteractionMapContext } from 'hooks/useInteractionMapContext' +import { + isModifierKey, + mapKey, + mouseButtonToName, + resolveInteractionEvent, +} from 'lib/keyboard' +import { InteractionMapItem } from 'machines/interactionMapMachine' +import { useEffect, useState } from 'react' +import { g } from 'vitest/dist/suite-IbNSsUWN' export function AllKeybindingsFields() { const { state } = useInteractionMapContext() @@ -6,24 +16,81 @@ export function AllKeybindingsFields() {
{state.context.interactionMap.map((item) => ( -
-

{item.title}

-
- {item.sequence.split(' ').map((chord) => ( - - {chord} - - ))} -
-
+ ))}
) } + +function KeybindingField({ item }: { item: InteractionMapItem }) { + const [isEditing, setIsEditing] = useState(false) + const [newSequence, setNewSequence] = useState('') + + useEffect(() => { + const blockOtherEvents = (e: KeyboardEvent | MouseEvent) => { + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + } + + const handleInteraction = (e: KeyboardEvent | MouseEvent) => { + blockOtherEvents(e) + + const resolvedInteraction = resolveInteractionEvent(e) + if (resolvedInteraction.isModifier) return + setNewSequence( + (prev) => prev + (prev.length ? ' ' : '') + resolvedInteraction.asString + ) + } + + const handleContextMenu = (e: MouseEvent) => { + blockOtherEvents(e) + } + + if (!isEditing) { + setNewSequence('') + globalThis?.window?.removeEventListener('keydown', handleInteraction) + globalThis?.window?.removeEventListener('mousedown', handleInteraction) + globalThis?.window?.removeEventListener('contextmenu', handleContextMenu) + } else { + globalThis?.window?.addEventListener('keydown', handleInteraction) + globalThis?.window?.addEventListener('mousedown', handleInteraction) + globalThis?.window?.addEventListener('contextmenu', handleContextMenu) + } + + return () => { + globalThis?.window?.removeEventListener('keydown', handleInteraction) + globalThis?.window?.removeEventListener('mousedown', handleInteraction) + globalThis?.window?.removeEventListener('contextmenu', handleContextMenu) + } + }, [isEditing]) + + return ( +
+

{item.title}

+
+ {(isEditing ? newSequence : item.sequence) + .split(' ') + .map((chord, i) => ( + + {chord} + + ))} +
+ +
+ ) +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index c2661b866..91ef24ddb 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -48,3 +48,8 @@ export const ONBOARDING_PROJECT_NAME = 'Tutorial Project $nn' export const INTERACTION_MAP_SEPARATOR = '+' /** The default KCL length expression */ export const KCL_DEFAULT_LENGTH = `5` +/** keybinding categories */ +export const KEYBINDING_CATEGORIES = { + MODELING: 'modeling', + COMMAND_BAR: 'command-bar', +} diff --git a/src/lib/keyboard.ts b/src/lib/keyboard.ts index 7da3ed7d5..1d7ffa2ef 100644 --- a/src/lib/keyboard.ts +++ b/src/lib/keyboard.ts @@ -1,3 +1,6 @@ +import { MouseButtonName } from 'machines/interactionMapMachine' +import { INTERACTION_MAP_SEPARATOR } from './constants' + /** * From https://github.com/JohannesKlauss/react-hotkeys-hook/blob/main/src/parseHotkeys.ts * we don't want to use the whole library (as cool as it is) because it attaches @@ -43,3 +46,48 @@ export function isModifierKey(key: string) { export function sortKeys(a: string, b: string) { return isModifierKey(a) ? -1 : isModifierKey(b) ? 1 : a.localeCompare(b) } + +export function mouseButtonToName( + button: MouseEvent['button'] +): MouseButtonName { + switch (button) { + case 0: + return 'LeftButton' + case 1: + return 'MiddleButton' + case 2: + return 'RightButton' + default: + return 'LeftButton' + } +} + +type ResolveKeymapEvent = { + action: string + modifiers: string[] + isModifier: boolean + asString: string +} + +export function resolveInteractionEvent( + event: MouseEvent | KeyboardEvent +): ResolveKeymapEvent { + // First, determine if this is a key or mouse event + const action = + 'key' in event ? mapKey(event.code) : mouseButtonToName(event.button) + + const modifiers = [ + event.ctrlKey && 'ctrl', + event.shiftKey && 'shift', + event.altKey && 'alt', + event.metaKey && 'meta', + ].filter((item) => item !== false) as string[] + return { + action, + modifiers, + isModifier: isModifierKey(action), + asString: [action, ...modifiers] + .sort(sortKeys) + .join(INTERACTION_MAP_SEPARATOR), + } +}