diff --git a/packages/devtools/client/app.vue b/packages/devtools/client/app.vue index 868e7dcf96..b7e2aea9f7 100644 --- a/packages/devtools/client/app.vue +++ b/packages/devtools/client/app.vue @@ -7,9 +7,10 @@ import { useCopy } from '~/composables/editor' import { setupFrameNav } from '~/composables/frame-nav' import { WS_DEBOUNCE_TIME } from '~/composables/rpc' import { registerCommands } from '~/composables/state-commands' +import { useDevToolsOptions } from '~/composables/storage-options' +import { isDevtoolsShortcut, watchShortcutLayoutMap } from '../src/runtime/shared/shortcut' import { wsConnectedOnce } from './composables/rpc' import { useSchemaInput } from './composables/state-schema' -import { useDevToolsOptions } from './composables/storage-options' import { setupClientRPC } from './setup/client-rpc' import 'floating-vue/dist/style.css' @@ -83,8 +84,10 @@ watchEffect(() => { document.documentElement.classList.toggle('light', !isDark) }) +watchShortcutLayoutMap() + useEventListener('keydown', (e) => { - if (e.code === 'KeyD' && e.altKey) { + if (isDevtoolsShortcut(e)) { client.value?.devtools.close() e.preventDefault() } diff --git a/packages/devtools/src/runtime/plugins/view/client.ts b/packages/devtools/src/runtime/plugins/view/client.ts index 0c5084cfbe..2aadaf5cb9 100644 --- a/packages/devtools/src/runtime/plugins/view/client.ts +++ b/packages/devtools/src/runtime/plugins/view/client.ts @@ -16,6 +16,7 @@ import { computed, markRaw, nextTick, reactive, ref, shallowReactive, shallowRef import { useAppConfig } from '#imports' import { initTimelineMetrics } from '../../function-metrics-helpers' +import { isDevtoolsShortcut, watchShortcutLayoutMap } from '../../shared/shortcut' // Host controls must update the visible Devframe viewer context, rather than // Vite's separate dock-registration context. Target the shared-frame anchor @@ -27,6 +28,14 @@ function getDevframeContext() { return getDevframeClientContext() as any } +function closeDevtoolsPanel() { + const ctx = getDevframeContext() + if (!ctx) + return + ctx.panel.session.open = false + void ctx.docks.switchEntry(null) +} + const clientRef = shallowRef() export { clientRef as client } @@ -57,13 +66,21 @@ export async function setupDevToolsClient({ devtools: { toggle() { const ctx = getDevframeContext() - if (ctx) - ctx.docks.toggleEntry(NUXT_DOCK_ANCHOR_ID) + if (!ctx) + return + if (!ctx.panel.session.open) { + ctx.panel.session.open = true + void ctx.docks.switchEntry(NUXT_DOCK_ANCHOR_ID) + } + else if (ctx.docks.selected?.id === NUXT_DOCK_ANCHOR_ID) { + closeDevtoolsPanel() + } + else { + void ctx.docks.switchEntry(NUXT_DOCK_ANCHOR_ID) + } }, close() { - const ctx = getDevframeContext() - if (ctx) - ctx.panel.session.open = false + closeDevtoolsPanel() }, open() { const ctx = getDevframeContext() @@ -205,8 +222,9 @@ export async function setupDevToolsClient({ clientRef.value = client // Shortcut to toggle devtools (opens Vite DevTools panel with Nuxt DevTools entry) + watchShortcutLayoutMap() window.addEventListener('keydown', (e) => { - if (e.code === 'KeyD' && e.altKey && e.shiftKey) + if (isDevtoolsShortcut(e, { shift: true })) client.devtools.toggle() }) } diff --git a/packages/devtools/src/runtime/shared/shortcut.ts b/packages/devtools/src/runtime/shared/shortcut.ts new file mode 100644 index 0000000000..004acec00c --- /dev/null +++ b/packages/devtools/src/runtime/shared/shortcut.ts @@ -0,0 +1,125 @@ +// DevTools is toggled with `Shift + Alt + D`. `KeyboardEvent.code` describes +// the physical key position, so it only matches the letter D on a QWERTY +// layout. Users on Workman, Colemak, etc. press a different physical key, and +// macOS rewrites the produced character while Option is held (`d` -> `∂`, +// with Shift -> `Î`). Resolve the keys that actually type `d` in the active +// layout and fall back to the produced key and the legacy key code. + +interface KeyboardLayoutMapLike { + forEach: (callback: (key: string, code: string) => void) => void +} + +interface KeyboardLike { + getLayoutMap: () => Promise + addEventListener?: (type: 'layoutchange', listener: () => void) => void +} + +export interface DevtoolsShortcutEvent { + altKey: boolean + shiftKey: boolean + ctrlKey?: boolean + metaKey?: boolean + code?: string + key?: string + keyCode?: number +} + +export interface DevtoolsShortcutOptions { + /** Require Shift to be held. Opening requires it; closing from the panel does not. */ + shift?: boolean + /** Physical codes that type `d` in the active layout. Exposed for testing. */ + codes?: ReadonlySet +} + +const D_KEY_CODE = 68 +const QWERTY_D_CODE = 'KeyD' + +const shortcutCodes = new Set([QWERTY_D_CODE]) + +function resetToQwertyShortcut() { + shortcutCodes.clear() + shortcutCodes.add(QWERTY_D_CODE) +} + +export function resolveShortcutCodes(layout: KeyboardLayoutMapLike): Set { + const codes = new Set() + layout.forEach((key, code) => { + if (key.toLowerCase() === 'd') + codes.add(code) + }) + return codes +} + +function getKeyboard(): KeyboardLike | undefined { + if (typeof navigator === 'undefined') + return + return (navigator as Navigator & { keyboard?: KeyboardLike }).keyboard +} + +let watching = false +let refreshRequest = 0 + +export function watchShortcutLayoutMap(): void { + if (watching) + return + watching = true + + const keyboard = getKeyboard() + if (!keyboard?.getLayoutMap) + return + + const refresh = () => { + const request = ++refreshRequest + shortcutCodes.clear() + try { + keyboard.getLayoutMap() + .then((layout) => { + if (request !== refreshRequest) + return + const codes = resolveShortcutCodes(layout) + if (!codes.size) { + resetToQwertyShortcut() + return + } + shortcutCodes.clear() + for (const code of codes) + shortcutCodes.add(code) + }) + .catch(() => { + if (request === refreshRequest) + resetToQwertyShortcut() + }) + } + catch { + // Blocked by a permissions policy. `code`, `key` and `keyCode` matching + // still work without the layout map. + resetToQwertyShortcut() + } + } + + refresh() + keyboard.addEventListener?.('layoutchange', refresh) +} + +export function isDevtoolsShortcut( + event: DevtoolsShortcutEvent, + options: DevtoolsShortcutOptions = {}, +): boolean { + if (!event.altKey) + return false + if (options.shift && !event.shiftKey) + return false + if (event.ctrlKey || event.metaKey) + return false + + const codes = options.codes ?? shortcutCodes + if (event.code && codes.has(event.code)) + return true + + if (event.key?.toLowerCase() === 'd') + return true + + // Deprecated, but it identifies the key produced by the active layout and + // ignores modifiers, which matters on macOS where Option rewrites `key`. + return event.keyCode === D_KEY_CODE +} diff --git a/packages/devtools/test/devtools-shortcut.test.ts b/packages/devtools/test/devtools-shortcut.test.ts new file mode 100644 index 0000000000..146d671978 --- /dev/null +++ b/packages/devtools/test/devtools-shortcut.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { isDevtoolsShortcut, resolveShortcutCodes } from '../src/runtime/shared/shortcut' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +function createEvent(overrides: Partial[0]> = {}) { + return { + altKey: true, + shiftKey: true, + code: 'KeyD', + key: 'd', + keyCode: 68, + ...overrides, + } +} + +// Physical `KeyW` on a Workman layout. Option turns the produced `d` into `Î`, +// but the key code stays the one for the letter D. +const workmanD = createEvent({ + code: 'KeyW', + key: 'Î', +}) + +describe('isDevtoolsShortcut', () => { + it('matches Shift + Alt + D on a QWERTY layout', () => { + expect(isDevtoolsShortcut(createEvent(), { shift: true })).toBe(true) + }) + + it('matches the QWERTY position even when Option rewrites the key', () => { + expect(isDevtoolsShortcut(createEvent({ key: 'Î' }), { shift: true })).toBe(true) + }) + + it('matches the key that types d on a non-QWERTY layout', () => { + const codes = resolveShortcutCodes(new Map([['KeyW', 'd'], ['KeyD', 's']])) + expect(isDevtoolsShortcut(workmanD, { shift: true, codes })).toBe(true) + }) + + it('ignores the physical QWERTY position when it types another letter', () => { + const codes = resolveShortcutCodes(new Map([['KeyD', 's'], ['KeyG', 'd']])) + expect(isDevtoolsShortcut(createEvent({ code: 'KeyD', key: 'S', keyCode: 83 }), { shift: true, codes })).toBe(false) + }) + + it('falls back to the legacy key code when the Keyboard API is unavailable', () => { + expect(isDevtoolsShortcut(workmanD, { shift: true })).toBe(true) + }) + + it('matches the produced key where Option does not rewrite it', () => { + expect(isDevtoolsShortcut(createEvent({ code: 'KeyG', key: 'd', keyCode: 0 }), { shift: true })).toBe(true) + }) + + it('ignores keys that do not type d', () => { + expect(isDevtoolsShortcut(createEvent({ code: 'KeyS', key: 's', keyCode: 83 }), { shift: true })).toBe(false) + }) + + it('requires Alt', () => { + expect(isDevtoolsShortcut(createEvent({ altKey: false }), { shift: true })).toBe(false) + }) + + it('requires Shift when opening', () => { + expect(isDevtoolsShortcut(createEvent({ shiftKey: false }), { shift: true })).toBe(false) + }) + + it('allows Shift to be optional when closing', () => { + expect(isDevtoolsShortcut(createEvent({ shiftKey: false }))).toBe(true) + expect(isDevtoolsShortcut(createEvent())).toBe(true) + }) + + it('ignores Ctrl and Meta combinations', () => { + expect(isDevtoolsShortcut(createEvent({ ctrlKey: true }), { shift: true })).toBe(false) + expect(isDevtoolsShortcut(createEvent({ metaKey: true }), { shift: true })).toBe(false) + }) +}) + +describe('resolveShortcutCodes', () => { + it('adds every code that types d in the active layout', () => { + const codes = resolveShortcutCodes(new Map([['KeyW', 'd'], ['KeyG', 'D'], ['KeyS', 's']])) + expect([...codes].sort()).toEqual(['KeyG', 'KeyW']) + }) + + it('does not keep physical positions that do not type d', () => { + const codes = resolveShortcutCodes(new Map([['KeyD', 's'], ['KeyG', 'd']])) + expect(codes.has('KeyD')).toBe(false) + expect(codes.has('KeyG')).toBe(true) + }) + + it('returns an empty set when the layout has no letter d', () => { + expect(resolveShortcutCodes(new Map()).size).toBe(0) + }) +}) + +describe('watchShortcutLayoutMap', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.resetModules() + }) + + it('keeps the shortcut codes empty while a refresh is pending', async () => { + vi.resetModules() + const pendingMap = deferred>() + vi.stubGlobal('navigator', { + keyboard: { + getLayoutMap: () => pendingMap.promise, + addEventListener: () => {}, + }, + }) + + const { isDevtoolsShortcut: isShortcut, watchShortcutLayoutMap } = await import('../src/runtime/shared/shortcut') + + watchShortcutLayoutMap() + + // Nothing is known about the layout yet, so the QWERTY position must not match. + const qwertyD = { altKey: true, shiftKey: true, code: 'KeyD', key: 'x', keyCode: 0 } + expect(isShortcut(qwertyD, { shift: true })).toBe(false) + + pendingMap.resolve(new Map([['KeyG', 'd']])) + await Promise.resolve() + await Promise.resolve() + + const colemakD = { altKey: true, shiftKey: true, code: 'KeyG', key: 'x', keyCode: 0 } + expect(isShortcut(colemakD, { shift: true })).toBe(true) + }) + + it('ignores a stale result from an older layout request', async () => { + vi.resetModules() + const first = deferred>() + const second = deferred>() + const pending = [first.promise, second.promise] + let onLayoutChange: (() => void) | undefined + + vi.stubGlobal('navigator', { + keyboard: { + getLayoutMap: () => pending.shift()!, + addEventListener: (_type: string, listener: () => void) => { + onLayoutChange = listener + }, + }, + }) + + const { isDevtoolsShortcut: isShortcut, watchShortcutLayoutMap } = await import('../src/runtime/shared/shortcut') + + watchShortcutLayoutMap() + onLayoutChange?.() + + // The second request reflects the new layout and resolves first. + second.resolve(new Map([['KeyG', 'd']])) + await Promise.resolve() + await Promise.resolve() + + // The first request belongs to the old layout and resolves last. + first.resolve(new Map([['KeyD', 'd']])) + await Promise.resolve() + await Promise.resolve() + + const colemakD = { altKey: true, shiftKey: true, code: 'KeyG', key: 'x', keyCode: 0 } + expect(isShortcut(colemakD, { shift: true })).toBe(true) + }) + + it('falls back to QWERTY when a layout refresh has no map', async () => { + vi.resetModules() + const first = deferred>() + const second = deferred>() + const pending = [first.promise, second.promise] + let onLayoutChange: (() => void) | undefined + + vi.stubGlobal('navigator', { + keyboard: { + getLayoutMap: () => pending.shift()!, + addEventListener: (_type: string, listener: () => void) => { + onLayoutChange = listener + }, + }, + }) + + const { isDevtoolsShortcut: isShortcut, watchShortcutLayoutMap } = await import('../src/runtime/shared/shortcut') + + watchShortcutLayoutMap() + first.resolve(new Map([['KeyW', 'd'], ['KeyD', 's']])) + await Promise.resolve() + await Promise.resolve() + + // Physical W types `d` on Workman. + const physicalW = { altKey: true, shiftKey: true, code: 'KeyW', key: 'w', keyCode: 87 } + expect(isShortcut(physicalW, { shift: true })).toBe(true) + + // The next layout is unknown, so W must no longer match as `d`. + onLayoutChange?.() + second.resolve(new Map()) + await Promise.resolve() + await Promise.resolve() + + expect(isShortcut(physicalW, { shift: true })).toBe(false) + }) +}) diff --git a/skills-lock.json b/skills-lock.json index f205f70b40..19336cbc3c 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -186,7 +186,7 @@ "source": "vuejs-ai/skills", "sourceType": "github", "skillPath": "skills/vue-best-practices/SKILL.md", - "computedHash": "70a50d6ee12edf24e0be9a79f59ea55a581961e8a38de2a20b96c7ae766ce3ef", + "computedHash": "2f4da0a95f19b225e784981413f727ff03bfdec7818a1e5bb0a477aab633095e", "via": "@antfu/skills" }, "vue-debug-guides": {