From e5a7de02683fe64ceebc50672d5302be853610d7 Mon Sep 17 00:00:00 2001 From: Sakana <15715093608@163.com> Date: Thu, 24 Sep 2026 22:12:15 +0800 Subject: [PATCH] fix(hub-ui): preserve keystrokes while recording shortcuts --- .../hub-ui/src/client/state/commands.test.ts | 67 +++++++++++++++++++ packages/hub-ui/src/client/state/commands.ts | 6 ++ 2 files changed, 73 insertions(+) create mode 100644 packages/hub-ui/src/client/state/commands.test.ts diff --git a/packages/hub-ui/src/client/state/commands.test.ts b/packages/hub-ui/src/client/state/commands.test.ts new file mode 100644 index 000000000..cf8d13c4b --- /dev/null +++ b/packages/hub-ui/src/client/state/commands.test.ts @@ -0,0 +1,67 @@ +import type { DevframeDocksUserSettings } from '@devframes/hub' +import type { DevframeRpcClient } from '@devframes/hub/client' +import { createSharedState } from 'devframe/utils/shared-state' +import { afterEach, expect, it, vi } from 'vitest' +import { effectScope, nextTick, shallowRef } from 'vue' +import { createCommandsContext } from './commands' +import { isMac } from './keybindings' +import { useDockPopupWindow } from './popup' + +vi.mock('./popup', () => ({ + useDockPopupWindow: vi.fn(() => shallowRef(null)), + useIsDockPopupOpen: () => shallowRef(false), +})) + +afterEach(() => vi.unstubAllGlobals()) + +it.each(['standalone', 'shadow-root', 'popup'] as const)('leaves recording keystrokes untouched in %s and resumes shortcuts outside the recorder', async (mode) => { + const listeners = new Map void>() + const host = { addEventListener: vi.fn((name, handler) => listeners.set(name, handler)) } + vi.stubGlobal('window', host) + const popup = shallowRef(null) + vi.mocked(useDockPopupWindow).mockReturnValue(popup) + const scope = effectScope() + try { + // eslint-disable-next-line slop/no-chained-type-assertions -- only sharedState is used by this command context fixture. + const rpc = { + sharedState: { get: async () => createSharedState({ initialValue: [] }) }, + } as unknown as DevframeRpcClient + const settings = createSharedState({ + initialValue: { docksHidden: [], docksCategoriesHidden: [], docksPinned: [], docksCustomOrder: {}, commandShortcuts: {} }, + }) + const context = await scope.run(() => createCommandsContext('standalone', rpc, settings))! + const action = vi.fn() + context.register({ id: 'test:palette', source: 'client', title: 'Palette', keybindings: [{ key: 'Mod+K' }], action }) + if (mode === 'popup') { + // eslint-disable-next-line slop/no-chained-type-assertions -- the popup fixture only needs the listener registration surface. + popup.value = host as unknown as Window + await nextTick() + } + const recorder = Object.assign(new EventTarget(), { classList: { contains: (name: string) => name === 'shortcut-key-input' } }) + const outside = Object.assign(new EventTarget(), { classList: { contains: () => false } }) + // eslint-disable-next-line slop/no-chained-type-assertions -- Node has no KeyboardEvent; this fixture supplies the fields the shortcut listener reads. + const event = Object.assign(new Event('keydown'), { + key: 'k', + metaKey: isMac, + ctrlKey: !isMac, + composedPath: () => [recorder, outside], + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }) as unknown as KeyboardEvent + Object.defineProperty(event, 'target', { value: mode === 'shadow-root' ? outside : recorder }) + const handler = listeners.get('keydown')! + handler(event) + expect(action).not.toHaveBeenCalled() + expect(event.preventDefault).not.toHaveBeenCalled() + expect(event.stopPropagation).not.toHaveBeenCalled() + + event.composedPath = () => [outside] + handler(event) + expect(action).toHaveBeenCalledOnce() + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(event.stopPropagation).toHaveBeenCalledOnce() + } + finally { + scope.stop() + } +}) diff --git a/packages/hub-ui/src/client/state/commands.ts b/packages/hub-ui/src/client/state/commands.ts index f600ef05c..5400bc3db 100644 --- a/packages/hub-ui/src/client/state/commands.ts +++ b/packages/hub-ui/src/client/state/commands.ts @@ -144,6 +144,12 @@ function setupShortcutListener( execute: (id: string, ...args: any[]) => Promise, ) { const handler = (e: KeyboardEvent) => { + // Let the shortcut editor record keys without executing their commands. + // composedPath reaches the input through shadow DOM; structural checks also + // work for elements belonging to the popup window's realm. + if (e.composedPath().some(target => (target as Element).classList?.contains('shortcut-key-input'))) + return + const pressed = normalizeKeyEvent(e) if (!pressed || pressed === 'Mod' || pressed === 'Shift' || pressed === 'Alt') return