RSS Git Download  Clone
Raw Blame History 5kB 119 lines
<script setup lang="ts">
/**
 * Command Palette — exact port of React CommandPaletteDialog.tsx.
 * Ctrl+K opens, search/filter shortcuts, arrow keys navigate, Enter executes.
 */
import { ref, computed, watch, nextTick } from 'vue'
import { storeToRefs } from 'pinia'
import { useCommonStore } from '../stores/common.store'
import { useI18nStore } from '../stores/i18n'
import { useThemeStore } from '../stores/theme'
import { getShortcuts, type ShortcutDef } from '../stores/shortcuts'

interface PaletteItem { label: string; description: string; shortcut: ShortcutDef }

const common = useCommonStore()
const strings = computed(() => useI18nStore().strings)
const { themeKey } = storeToRefs(useThemeStore())
const isDark = computed(() => ['dark', 'darkNeu', 'darkoBluo', 'matrix'].includes(themeKey.value))

const search = ref('')
const selectedIndex = ref(0)
const inputEl = ref<HTMLInputElement>()
const listEl = ref<HTMLDivElement>()

const allItems = computed((): PaletteItem[] => {
    const seen = new Set<string>()
    const items: PaletteItem[] = []
    for (const s of getShortcuts()) {
        if (seen.has(s.descriptionKey)) continue
        seen.add(s.descriptionKey)
        items.push({ label: s.label, description: strings.value?.label?.[s.descriptionKey] || s.descriptionKey, shortcut: s })
    }
    return items
})

const filtered = computed(() => {
    const q = search.value.toLowerCase().trim()
    if (!q) return allItems.value
    return allItems.value.filter(i => i.description.toLowerCase().includes(q) || i.label.toLowerCase().includes(q))
})

watch(() => common.commandPaletteOpen, (open) => {
    if (open) {
        search.value = ''
        selectedIndex.value = 0
        nextTick(() => setTimeout(() => inputEl.value?.focus(), 50))
    }
})

watch(selectedIndex, () => {
    if (!listEl.value) return
    const items = listEl.value.querySelectorAll('.p3xr-cmd-palette-item')
    items[selectedIndex.value]?.scrollIntoView({ block: 'nearest' })
})

function execute(item: PaletteItem) {
    common.setCommandPaletteOpen(false)
    item.shortcut.action()
}

function onKeyDown(e: KeyboardEvent) {
    if (e.key === 'ArrowDown') { e.preventDefault(); selectedIndex.value = Math.min(selectedIndex.value + 1, filtered.value.length - 1) }
    else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIndex.value = Math.max(selectedIndex.value - 1, 0) }
    else if (e.key === 'Enter') { e.preventDefault(); if (filtered.value[selectedIndex.value]) execute(filtered.value[selectedIndex.value]) }
    else if (e.key === 'Escape') { common.setCommandPaletteOpen(false) }
}

function onSearchInput(e: Event) {
    search.value = (e.target as HTMLInputElement).value
    selectedIndex.value = 0
}

const hoverBg = computed(() => isDark.value ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)')
const activeBg = computed(() => isDark.value ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)')
</script>

<template>
    <v-dialog v-model="common.commandPaletteOpen" max-width="500" min-width="360" @keydown.escape="common.setCommandPaletteOpen(false)">
        <v-card style="border-radius: 8px; overflow: hidden;">
            <!-- Search input -->
            <div style="display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.12);">
                <v-icon style="opacity: 0.5;">mdi-magnify</v-icon>
                <input
                    ref="inputEl"
                    :value="search"
                    @input="onSearchInput"
                    @keydown="onKeyDown"
                    :placeholder="strings?.label?.commandPalette"
                    autocomplete="off"
                    style="flex: 1; border: none; outline: none; background: transparent; color: inherit; font-size: 16px; font-family: inherit;"
                />
            </div>
            <!-- Items -->
            <div ref="listEl" style="max-height: 300px; overflow-y: auto;">
                <div v-for="(item, i) in filtered" :key="item.label"
                    class="p3xr-cmd-palette-item"
                    :style="{
                        display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                        padding: '10px 16px', cursor: 'pointer',
                        backgroundColor: i === selectedIndex ? activeBg : 'transparent',
                    }"
                    @click="execute(item)">
                    <span style="font-size: 14px;">{{ item.description }}</span>
                    <kbd style="padding: 2px 8px; border-radius: 4px; font-size: 12px; font-family: 'Roboto Mono', monospace; white-space: nowrap; background-color: rgba(var(--v-theme-on-surface), 0.08);">{{ item.label }}</kbd>
                </div>
                <div v-if="filtered.length === 0" style="padding: 16px; text-align: center; opacity: 0.5;">
                    {{ strings?.label?.noResults }}
                </div>
            </div>
        </v-card>
    </v-dialog>
</template>

<style scoped>
.p3xr-cmd-palette-item:hover {
    background-color: rgba(var(--v-theme-on-surface), 0.04) !important;
}
</style>