Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/devtools/client/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
}
Expand Down
19 changes: 15 additions & 4 deletions packages/devtools/src/runtime/plugins/view/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,13 +58,22 @@ 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) {
void ctx.docks.toggleEntry(NUXT_DOCK_ANCHOR_ID)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
else {
ctx.panel.session.open = true
void ctx.docks.switchEntry(NUXT_DOCK_ANCHOR_ID)
}
},
close() {
const ctx = getDevframeContext()
if (ctx)
if (ctx) {
ctx.panel.session.open = false
void ctx.docks.switchEntry(null)
}
},
open() {
const ctx = getDevframeContext()
Expand Down Expand Up @@ -205,8 +215,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()
})
}
Expand Down
114 changes: 114 additions & 0 deletions packages/devtools/src/runtime/shared/shortcut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// 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<KeyboardLayoutMapLike>
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<string>
}

const D_KEY_CODE = 68
const QWERTY_D_CODE = 'KeyD'

const shortcutCodes = new Set([QWERTY_D_CODE])

export function resolveShortcutCodes(layout: KeyboardLayoutMapLike): Set<string> {
const codes = new Set<string>()
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
try {
keyboard.getLayoutMap()
.then((layout) => {
if (request !== refreshRequest)
return
const codes = resolveShortcutCodes(layout)
// Keep the QWERTY fallback if the map unexpectedly has no letter D.
if (!codes.size)
return
shortcutCodes.clear()
for (const code of codes)
shortcutCodes.add(code)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
})
.catch(() => {})
}
catch {
// Blocked by a permissions policy. `code`, `key` and `keyCode` matching
// still work without the layout map.
}
}

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
}
137 changes: 137 additions & 0 deletions packages/devtools/test/devtools-shortcut.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isDevtoolsShortcut, resolveShortcutCodes } from '../src/runtime/shared/shortcut'

function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((r) => {
resolve = r
})
return { promise, resolve }
}

function createEvent(overrides: Partial<Parameters<typeof isDevtoolsShortcut>[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('ignores a stale result from an older layout request', async () => {
vi.resetModules()
const first = deferred<Map<string, string>>()
const second = deferred<Map<string, string>>()
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)
})
})
2 changes: 1 addition & 1 deletion skills-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading