diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index 1d9663f..d86a6fb 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -6,6 +6,8 @@ import { inertia } from 'popmotion' import icon from '../../build/icon.png?asset' +// FIXME: electron i18n + let globalMouseTracker: ReturnType | null = null let mainWindow: BrowserWindow let currentAnimationX: { stop: () => void } | null = null diff --git a/apps/stage-tamagotchi/src/renderer/locales/en.yml b/apps/stage-tamagotchi/src/renderer/locales/en.yml index 78c6299..d079d9e 100644 --- a/apps/stage-tamagotchi/src/renderer/locales/en.yml +++ b/apps/stage-tamagotchi/src/renderer/locales/en.yml @@ -65,6 +65,14 @@ settings: voices: Voice quit: Quit viewer: Viewer + shortcuts: + title: Shortcuts + window: + move: Move the window + resize: Resize the window + debug: Toggle developer tools + press_keys: Press keys... + other: Other stage: chat: message: diff --git a/apps/stage-tamagotchi/src/renderer/locales/zh-CN.yml b/apps/stage-tamagotchi/src/renderer/locales/zh-CN.yml index db25141..bcd15dd 100644 --- a/apps/stage-tamagotchi/src/renderer/locales/zh-CN.yml +++ b/apps/stage-tamagotchi/src/renderer/locales/zh-CN.yml @@ -52,6 +52,14 @@ settings: voices: 声线 quit: 退出 viewer: 查看器 + shortcuts: + title: 快捷键 + window: + move: 移动窗口 + resize: 调整窗口大小 + debug: 切换开发者模式 + press_keys: 按下快捷键... + other: 其他 stage: message: 消息 select-a-audio-input: 选择一个音频输入设备 diff --git a/apps/stage-tamagotchi/src/renderer/src/composables/window-shortcuts.ts b/apps/stage-tamagotchi/src/renderer/src/composables/window-shortcuts.ts index 8a8f9e9..f76a677 100644 --- a/apps/stage-tamagotchi/src/renderer/src/composables/window-shortcuts.ts +++ b/apps/stage-tamagotchi/src/renderer/src/composables/window-shortcuts.ts @@ -1,39 +1,56 @@ -import { onMounted, onUnmounted } from 'vue' +import type { EffectScope } from 'vue' + +import { useShortcutsStore } from '@renderer/stores/shortcuts' +import { useMagicKeys, whenever } from '@vueuse/core' +import { storeToRefs } from 'pinia' +import { computed, effectScope, watch } from 'vue' import { useWindowControlStore } from '../stores/window-controls' import { WindowControlMode } from '../types/window-controls' export function useWindowShortcuts() { const windowStore = useWindowControlStore() + const magicKeys = useMagicKeys() - function handleKeydown(event: KeyboardEvent) { - // Ctrl/Cmd + Shift + D for debug mode - if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === 'd') { - windowStore.setMode(WindowControlMode.DEBUG) - windowStore.toggleControl() - } - // Ctrl/Cmd + M for move mode - if ((event.ctrlKey || event.metaKey) && event.key === 'm') { - windowStore.setMode(WindowControlMode.MOVE) - windowStore.toggleControl() - } - // Ctrl/Cmd + R for resize mode - if ((event.ctrlKey || event.metaKey) && event.key === 'r') { - windowStore.setMode(WindowControlMode.RESIZE) - windowStore.toggleControl() - } - // Escape to exit any mode - if (event.key === 'Escape') { - windowStore.setMode(WindowControlMode.DEFAULT) - windowStore.toggleControl() - } - } + const { shortcuts } = storeToRefs(useShortcutsStore()) + const handlers = computed(() => [ + { + handle: () => { + windowStore.setMode(WindowControlMode.MOVE) + windowStore.toggleControl() + }, + shortcut: shortcuts.value.find(shortcut => shortcut.type === 'move')?.shortcut, + }, + { + handle: () => { + windowStore.setMode(WindowControlMode.RESIZE) + windowStore.toggleControl() + }, + shortcut: shortcuts.value.find(shortcut => shortcut.type === 'resize')?.shortcut, + }, + { + handle: () => { + windowStore.setMode(WindowControlMode.DEBUG) + windowStore.toggleControl() + }, + shortcut: shortcuts.value.find(shortcut => shortcut.type === 'debug')?.shortcut, + }, + ]) - onMounted(() => { - window.addEventListener('keydown', handleKeydown) - }) + let currentScope: EffectScope | null = null + watch(handlers, () => { + if (currentScope) { + currentScope.stop() + } - onUnmounted(() => { - window.removeEventListener('keydown', handleKeydown) - }) + currentScope = effectScope() + currentScope.run(() => { + handlers.value.forEach((handler) => { + if (!handler.shortcut) { + return + } + whenever(magicKeys[handler.shortcut], handler.handle) + }) + }) + }, { immediate: true }) } diff --git a/apps/stage-tamagotchi/src/renderer/src/pages/settings.vue b/apps/stage-tamagotchi/src/renderer/src/pages/settings.vue index 530a94c..1798a9f 100644 --- a/apps/stage-tamagotchi/src/renderer/src/pages/settings.vue +++ b/apps/stage-tamagotchi/src/renderer/src/pages/settings.vue @@ -3,17 +3,29 @@ import type { Voice } from '@proj-airi/stage-ui/constants' import { voiceList } from '@proj-airi/stage-ui/constants' import { useLLM, useSettings } from '@proj-airi/stage-ui/stores' +import { useShortcutsStore } from '@renderer/stores/shortcuts' +import { useEventListener } from '@vueuse/core' import { storeToRefs } from 'pinia' -import { onMounted, ref, watch } from 'vue' +import { computed, onMounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' const { t, locale } = useI18n() const settings = useSettings() +const { shortcuts } = storeToRefs(useShortcutsStore()) const supportedModels = ref<{ id: string, name?: string }[]>([]) const { models } = useLLM() const { openAiModel, openAiApiBaseURL, openAiApiKey, elevenlabsVoiceEnglish, elevenlabsVoiceJapanese, language } = storeToRefs(settings) +const recordingFor = ref(null) +const recordingKeys = ref<{ + modifier: string[] + key: string +}>({ + modifier: [], + key: '', +}) + function handleModelChange(event: Event) { const target = event.target as HTMLSelectElement const found = supportedModels.value.find(m => m.id === target.value) @@ -69,6 +81,68 @@ onMounted(async () => { function handleQuit() { window.electron.ipcRenderer.send('quit') } + +// Add function to handle shortcut recording +function startRecording(shortcut: typeof shortcuts.value[0]) { + recordingFor.value = shortcut.type +} + +function isModifierKey(key: string) { + return ['Shift', 'Control', 'Alt', 'Meta'].includes(key) +} + +// Handle key combinations +useEventListener('keydown', (e) => { + if (!recordingFor.value) + return + + e.preventDefault() + + if (isModifierKey(e.key)) { + if (recordingKeys.value.modifier.includes(e.key)) + return + + recordingKeys.value.modifier.push(e.key) + + return + } + + if (recordingKeys.value.modifier.length === 0) + return + + recordingKeys.value.key = e.key.toUpperCase() + + const shortcut = shortcuts.value.find(s => s.type === recordingFor.value) + if (shortcut) + shortcut.shortcut = `${recordingKeys.value.modifier.join('+')}+${recordingKeys.value.key}` + + recordingKeys.value = { + modifier: [], + key: '', + } + recordingFor.value = null +}, { passive: false }) + +// Add click outside handler to cancel recording +useEventListener('click', (e) => { + if (recordingFor.value) { + const target = e.target as HTMLElement + if (!target.closest('.shortcut-item')) { + recordingFor.value = null + } + } +}) + +const pressKeysMessage = computed(() => { + if (recordingKeys.value.modifier.length === 0) + return t('settings.press_keys') + + return `${t('settings.press_keys')}: ${recordingKeys.value.modifier.join('+')}+${recordingKeys.value.key}` +}) + +function isConflict(shortcut: typeof shortcuts.value[0]) { + return shortcuts.value.some(s => s.type !== shortcut.type && s.shortcut === shortcut.shortcut) +}