diff --git a/.changeset/curly-news-work.md b/.changeset/curly-news-work.md new file mode 100644 index 00000000..92378511 --- /dev/null +++ b/.changeset/curly-news-work.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools': minor +--- + +add inspectHotkey to devtools configuration diff --git a/docs/configuration.md b/docs/configuration.md index c19be03d..20e586f9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,12 +45,21 @@ The `config` object is mainly focused around user interaction with the devtools - `openHotkey` - The hotkey set to open the devtools ```ts -type ModifierKey = 'Alt' | 'Control' | 'Meta' | 'Shift'; +type ModifierKey = 'Alt' | 'Control' | 'Meta' | 'Shift' | 'CtrlOrMeta'; type KeyboardKey = ModifierKey | (string & {}); { openHotkey: Array } ``` +- `inspectHotkey` - The hotkey set to open the source inspector + +```ts +type ModifierKey = 'Alt' | 'Control' | 'Meta' | 'Shift' | 'CtrlOrMeta'; +type KeyboardKey = ModifierKey | (string & {}); + +{ inspectHotkey: Array } +``` + - `requireUrlFlag` - Requires a flag present in the url to enable devtools ```ts diff --git a/packages/devtools/src/components/source-inspector.tsx b/packages/devtools/src/components/source-inspector.tsx index b5f51ecb..519bcb51 100644 --- a/packages/devtools/src/components/source-inspector.tsx +++ b/packages/devtools/src/components/source-inspector.tsx @@ -4,7 +4,11 @@ import { createElementSize } from '@solid-primitives/resize-observer' import { useKeyDownList } from '@solid-primitives/keyboard' import { createEventListener } from '@solid-primitives/event-listener' +import { useDevtoolsSettings } from '../context/use-devtools-context' +import { isHotkeyCombinationPressed } from '../utils/hotkey' + export const SourceInspector = () => { + const { settings } = useDevtoolsSettings() const highlightStateInit = () => ({ element: null as HTMLElement | null, bounding: { width: 0, height: 0, left: 0, top: 0 }, @@ -25,12 +29,9 @@ export const SourceInspector = () => { }) const downList = useKeyDownList() + const isHighlightingKeysHeld = createMemo(() => { - const keys = downList() - const isShiftHeld = keys.includes('SHIFT') - const isCtrlHeld = keys.includes('CONTROL') - const isMetaHeld = keys.includes('META') - return isShiftHeld && (isCtrlHeld || isMetaHeld) + return isHotkeyCombinationPressed(downList(), settings().inspectHotkey) }) createEffect(() => { diff --git a/packages/devtools/src/context/devtools-store.ts b/packages/devtools/src/context/devtools-store.ts index da30780a..f312bc6a 100644 --- a/packages/devtools/src/context/devtools-store.ts +++ b/packages/devtools/src/context/devtools-store.ts @@ -1,13 +1,15 @@ import type { TabName } from '../tabs' import type { TanStackDevtoolsPlugin } from './devtools-context' -type ModifierKey = 'Alt' | 'Control' | 'Meta' | 'Shift' +type ModifierKey = 'Alt' | 'Control' | 'Meta' | 'Shift' | 'CtrlOrMeta' type KeyboardKey = ModifierKey | (string & {}) +export type { ModifierKey, KeyboardKey } export const keyboardModifiers: Array = [ 'Alt', 'Control', 'Meta', 'Shift', + 'CtrlOrMeta', ] type TriggerPosition = @@ -47,9 +49,14 @@ export type DevtoolsStore = { panelLocation: 'top' | 'bottom' /** * The hotkey to open the dev tools - * @default "shift+a" + * @default ["Shift", "A"] */ openHotkey: Array + /** + * The hotkey to open the source inspector + * @default ["Shift", "CtrlOrMeta"] + */ + inspectHotkey: Array /** * Whether to require the URL flag to open the dev tools * @default false @@ -93,6 +100,7 @@ export const initialState: DevtoolsStore = { position: 'bottom-right', panelLocation: 'bottom', openHotkey: ['Shift', 'A'], + inspectHotkey: ['Shift', 'CtrlOrMeta'], requireUrlFlag: false, urlFlag: 'tanstack-devtools', theme: diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx index 7276854a..5a68a04d 100644 --- a/packages/devtools/src/devtools.tsx +++ b/packages/devtools/src/devtools.tsx @@ -3,6 +3,7 @@ import { createShortcut } from '@solid-primitives/keyboard' import { Portal } from 'solid-js/web' import { ThemeContextProvider } from '@tanstack/devtools-ui' import { devtoolsEventClient } from '@tanstack/devtools-client' + import { useDevtoolsSettings, useHeight, @@ -11,13 +12,12 @@ import { } from './context/use-devtools-context' import { useDisableTabbing } from './hooks/use-disable-tabbing' import { TANSTACK_DEVTOOLS } from './utils/storage' +import { getHotkeyPermutations } from './utils/hotkey' import { Trigger } from './components/trigger' import { MainPanel } from './components/main-panel' import { ContentPanel } from './components/content-panel' import { Tabs } from './components/tabs' import { TabContent } from './components/tab-content' -import { keyboardModifiers } from './context/devtools-store' -import { getAllPermutations } from './utils/sanitize' import { usePiPWindow } from './context/pip-context' import { SourceInspector } from './components/source-inspector' @@ -165,18 +165,9 @@ export default function DevTools() { } }) createEffect(() => { - // we create all combinations of modifiers - const modifiers = settings().openHotkey.filter((key) => - keyboardModifiers.includes(key as any), - ) - const nonModifiers = settings().openHotkey.filter( - (key) => !keyboardModifiers.includes(key as any), - ) - - const allModifierCombinations = getAllPermutations(modifiers) - - for (const combination of allModifierCombinations) { - const permutation = [...combination, ...nonModifiers] + const permutations = getHotkeyPermutations(settings().openHotkey) + + for (const permutation of permutations) { createShortcut(permutation, () => { toggleOpen() }) diff --git a/packages/devtools/src/styles/use-styles.ts b/packages/devtools/src/styles/use-styles.ts index b8fb32b0..cda20cad 100644 --- a/packages/devtools/src/styles/use-styles.ts +++ b/packages/devtools/src/styles/use-styles.ts @@ -466,6 +466,11 @@ const stylesFactory = (theme: DevtoolsStore['settings']['theme']) => { display: flex; gap: 0.5rem; `, + settingsStack: css` + display: flex; + flex-direction: column; + gap: 1rem; + `, // No Plugins Fallback Styles noPluginsFallback: css` diff --git a/packages/devtools/src/tabs/hotkey-config.tsx b/packages/devtools/src/tabs/hotkey-config.tsx new file mode 100644 index 00000000..461e30ed --- /dev/null +++ b/packages/devtools/src/tabs/hotkey-config.tsx @@ -0,0 +1,97 @@ +import { Show } from 'solid-js' +import { Button, Input } from '@tanstack/devtools-ui' + +import { uppercaseFirstLetter } from '../utils/sanitize' +import { useStyles } from '../styles/use-styles' +import type { KeyboardKey } from '../context/devtools-store' + +interface HotkeyConfigProps { + title: string + description: string + hotkey: Array + modifiers: Array + onHotkeyChange: (hotkey: Array) => void +} + +const MODIFIER_DISPLAY_NAMES: Record = { + Shift: 'Shift', + Alt: 'Alt', + Meta: 'Meta', + Control: 'Control', + CtrlOrMeta: 'Ctrl Or Meta', +} + +export const HotkeyConfig = (props: HotkeyConfigProps) => { + const styles = useStyles() + + const toggleModifier = (modifier: KeyboardKey) => { + if (props.hotkey.includes(modifier)) { + props.onHotkeyChange(props.hotkey.filter((key) => key !== modifier)) + } else { + const existingModifiers = props.hotkey.filter((key) => + props.modifiers.includes(key as any), + ) + const otherKeys = props.hotkey.filter( + (key) => !props.modifiers.includes(key as any), + ) + props.onHotkeyChange([...existingModifiers, modifier, ...otherKeys]) + } + } + + const getNonModifierValue = () => { + return props.hotkey + .filter((key) => !props.modifiers.includes(key as any)) + .join('+') + } + + const handleKeyInput = (input: string) => { + const makeModifierArray = (key: string) => { + if (key.length === 1) return [uppercaseFirstLetter(key)] + const modifiersArray: Array = [] + for (const character of key) { + const newLetter = uppercaseFirstLetter(character) + if (!modifiersArray.includes(newLetter)) modifiersArray.push(newLetter) + } + return modifiersArray + } + + const hotkeyModifiers = props.hotkey.filter((key) => + props.modifiers.includes(key as any), + ) + const newKeys = input + .split('+') + .flatMap((key) => makeModifierArray(key)) + .filter(Boolean) + props.onHotkeyChange([...hotkeyModifiers, ...newKeys]) + } + + const getDisplayHotkey = () => { + return props.hotkey.join(' + ') + } + + return ( +
+

{props.description}

+
+ + {props.modifiers.map((modifier) => ( + + ))} + +
+ + Final shortcut is: {getDisplayHotkey()} +
+ ) +} diff --git a/packages/devtools/src/tabs/settings-tab.tsx b/packages/devtools/src/tabs/settings-tab.tsx index 147e8f7d..60a42eea 100644 --- a/packages/devtools/src/tabs/settings-tab.tsx +++ b/packages/devtools/src/tabs/settings-tab.tsx @@ -1,6 +1,5 @@ -import { Show, createMemo } from 'solid-js' +import { Show } from 'solid-js' import { - Button, Checkbox, Input, MainPanel, @@ -16,32 +15,18 @@ import { Link, SettingsCog, } from '@tanstack/devtools-ui/icons' + import { useDevtoolsSettings } from '../context/use-devtools-context' -import { uppercaseFirstLetter } from '../utils/sanitize' import { useStyles } from '../styles/use-styles' -import type { ModifierKey } from '@solid-primitives/keyboard' +import { HotkeyConfig } from './hotkey-config' +import type { KeyboardKey } from '../context/devtools-store' export const SettingsTab = () => { const { setSettings, settings } = useDevtoolsSettings() const styles = useStyles() - const hotkey = createMemo(() => settings().openHotkey) - const modifiers: Array = ['Control', 'Alt', 'Meta', 'Shift'] - const changeHotkey = (newHotkey: ModifierKey) => () => { - if (hotkey().includes(newHotkey)) { - return setSettings({ - openHotkey: hotkey().filter((key) => key !== newHotkey), - }) - } - const existingModifiers = hotkey().filter((key) => - modifiers.includes(key as any), - ) - const otherModifiers = hotkey().filter( - (key) => !modifiers.includes(key as any), - ) - setSettings({ - openHotkey: [...existingModifiers, newHotkey, ...otherModifiers], - }) - } + + const modifiers: Array = ['CtrlOrMeta', 'Alt', 'Shift'] + return (
@@ -144,71 +129,23 @@ export const SettingsTab = () => { Customize keyboard shortcuts for quick access. -
-
- - - - - - -
- !['Shift', 'Meta', 'Alt', 'Ctrl'].includes(key)) - .join('+')} - onChange={(e) => { - const makeModifierArray = (key: string) => { - if (key.length === 1) return [uppercaseFirstLetter(key)] - const modifiers: Array = [] - for (const character of key) { - const newLetter = uppercaseFirstLetter(character) - if (!modifiers.includes(newLetter)) modifiers.push(newLetter) - } - return modifiers - } - const modifiers = e - .split('+') - .flatMap((key) => makeModifierArray(key)) - .filter(Boolean) - return setSettings({ - openHotkey: [ - ...hotkey().filter((key) => - ['Shift', 'Meta', 'Alt', 'Ctrl'].includes(key), - ), - ...modifiers, - ], - }) - }} + +
+ setSettings({ openHotkey: hotkey })} + /> + + setSettings({ inspectHotkey: hotkey })} /> - Final shortcut is: {hotkey().join(' + ')}
diff --git a/packages/devtools/src/utils/hotkey.test.ts b/packages/devtools/src/utils/hotkey.test.ts new file mode 100644 index 00000000..ea654e4b --- /dev/null +++ b/packages/devtools/src/utils/hotkey.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { + getHotkeyPermutations, + isHotkeyCombinationPressed, + normalizeHotkey, +} from './hotkey' +import type { KeyboardKey } from '../context/devtools-store' + +describe('hotkey utilities', () => { + describe('normalizeHotkey', () => { + it('should return unchanged array when CtrlOrMeta is not present', () => { + const hotkey: Array = ['Shift', 'A'] + const result = normalizeHotkey(hotkey) + expect(result).toEqual([['Shift', 'A']]) + }) + + it('should expand CtrlOrMeta to Control and Meta variants', () => { + const hotkey: Array = ['Shift', 'CtrlOrMeta'] + const result = normalizeHotkey(hotkey) + expect(result).toHaveLength(2) + expect(result).toContainEqual(['Shift', 'Control']) + expect(result).toContainEqual(['Shift', 'Meta']) + }) + }) + + describe('getHotkeyPermutations', () => { + it('should generate permutations for modifiers in any order', () => { + const hotkey: Array = ['Shift', 'Control', 'A'] + const result = getHotkeyPermutations(hotkey) + expect(result).toContainEqual(['Shift', 'Control', 'A']) + expect(result).toContainEqual(['Control', 'Shift', 'A']) + }) + + it('should handle CtrlOrMeta expansion with multiple permutations', () => { + const hotkey: Array = ['Shift', 'CtrlOrMeta'] + const result = getHotkeyPermutations(hotkey) + expect(result).toContainEqual(['Shift', 'Control']) + expect(result).toContainEqual(['Control', 'Shift']) + expect(result).toContainEqual(['Shift', 'Meta']) + expect(result).toContainEqual(['Meta', 'Shift']) + }) + + it('should handle single key hotkey with no modifiers', () => { + const hotkey: Array = ['A'] + const result = getHotkeyPermutations(hotkey) + expect(result).toEqual([['A']]) + }) + + it('should not have duplicate permutations', () => { + const hotkey: Array = ['Shift', 'Alt', 'A'] + const result = getHotkeyPermutations(hotkey) + const stringified = result.map((combo) => JSON.stringify(combo)) + const unique = new Set(stringified) + expect(unique.size).toBe(stringified.length) + }) + }) + + describe('isHotkeyCombinationPressed', () => { + it('should match exact key combination', () => { + expect(isHotkeyCombinationPressed(['Shift', 'A'], ['Shift', 'A'])).toBe( + true, + ) + }) + + it('should be case-insensitive', () => { + expect(isHotkeyCombinationPressed(['shift', 'a'], ['Shift', 'A'])).toBe( + true, + ) + }) + + it('should match regardless of modifier order', () => { + expect( + isHotkeyCombinationPressed( + ['A', 'Control', 'Shift'], + ['Shift', 'Control', 'A'], + ), + ).toBe(true) + }) + + it('should handle CtrlOrMeta with Control', () => { + expect( + isHotkeyCombinationPressed( + ['Shift', 'Control'], + ['Shift', 'CtrlOrMeta'], + ), + ).toBe(true) + }) + + it('should handle CtrlOrMeta with Meta', () => { + expect( + isHotkeyCombinationPressed(['Shift', 'Meta'], ['Shift', 'CtrlOrMeta']), + ).toBe(true) + }) + + it('should reject incomplete key combinations', () => { + expect(isHotkeyCombinationPressed(['Shift'], ['Shift', 'A'])).toBe(false) + }) + + it('should reject extra keys', () => { + expect( + isHotkeyCombinationPressed(['Shift', 'A', 'B'], ['Shift', 'A']), + ).toBe(false) + }) + + it('should handle single key hotkey', () => { + expect(isHotkeyCombinationPressed(['A'], ['A'])).toBe(true) + }) + }) +}) diff --git a/packages/devtools/src/utils/hotkey.ts b/packages/devtools/src/utils/hotkey.ts new file mode 100644 index 00000000..fe46db8b --- /dev/null +++ b/packages/devtools/src/utils/hotkey.ts @@ -0,0 +1,66 @@ +import { keyboardModifiers } from '../context/devtools-store' +import { getAllPermutations } from './sanitize' + +import type { KeyboardKey, ModifierKey } from '../context/devtools-store' + +/** Expands CtrlOrMeta into separate Control and Meta variants */ +export const normalizeHotkey = ( + keys: Array, +): Array> => { + // no normalization needed if CtrlOrMeta not used + if (!keys.includes('CtrlOrMeta')) { + return [keys] + } + + return [ + keys.map((key) => (key === 'CtrlOrMeta' ? 'Control' : key)), + keys.map((key) => (key === 'CtrlOrMeta' ? 'Meta' : key)), + ] +} + +/** + * Generates all keyboard permutations for a given hotkey configuration + * Handles CtrlOrMeta expansion and creates all possible combinations + */ +export const getHotkeyPermutations = ( + hotkey: Array, +): Array> => { + const normalizedHotkeys = normalizeHotkey(hotkey) + + return normalizedHotkeys.flatMap((normalizedHotkey) => { + const modifiers = normalizedHotkey.filter((key) => + keyboardModifiers.includes(key as any), + ) as Array + + const nonModifiers = normalizedHotkey.filter( + (key) => !keyboardModifiers.includes(key as any), + ) + + // handle case with no modifiers (just non-modifier keys) + if (modifiers.length === 0) { + return [nonModifiers] + } + + const allModifierCombinations = getAllPermutations(modifiers) + return allModifierCombinations.map((combo) => [...combo, ...nonModifiers]) + }) +} + +/** Checks if the currently pressed keys match any of the hotkey permutations */ +export const isHotkeyCombinationPressed = ( + keys: Array, + hotkey: Array, +): boolean => { + const permutations = getHotkeyPermutations(hotkey) + const pressedKeys = keys.map((key) => key.toUpperCase()) + + return permutations.some( + (combo) => + // every key in the combo must be pressed + combo.every((key) => pressedKeys.includes(String(key).toUpperCase())) && + // and no extra keys beyond the combo + pressedKeys.every((key) => + combo.map((k) => String(k).toUpperCase()).includes(key), + ), + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ae61a7c..3fe24dab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -453,6 +453,8 @@ importers: specifier: ^4.2.4 version: 4.2.4 + examples/react/start/generated/prisma: {} + examples/react/time-travel: dependencies: '@tanstack/devtools-event-client':