diff --git a/frontend/bindings/voidraft/internal/services/keybindingservice.ts b/frontend/bindings/voidraft/internal/services/keybindingservice.ts index 89b3e904..2a4752a5 100644 --- a/frontend/bindings/voidraft/internal/services/keybindingservice.ts +++ b/frontend/bindings/voidraft/internal/services/keybindingservice.ts @@ -64,6 +64,14 @@ export function ResetKeyBindings(): Promise & { cancel(): void } { return $resultPromise; } +/** + * ResetSingleKeyBinding 重置单个快捷键到默认值 + */ +export function ResetSingleKeyBinding(id: number): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(160366089, id) as any; + return $resultPromise; +} + /** * ServiceStartup 服务启动 */ diff --git a/frontend/src/composables/index.ts b/frontend/src/composables/index.ts index 38a85104..5bcc2163 100644 --- a/frontend/src/composables/index.ts +++ b/frontend/src/composables/index.ts @@ -1,5 +1,8 @@ export { useConfirm } from './useConfirm'; export type { UseConfirmOptions } from './useConfirm'; +export { useKeyRecorder } from './useKeyRecorder'; +export type { KeyRecorderState, UseKeyRecorderOptions, UseKeyRecorderReturn } from './useKeyRecorder'; + export { usePolling } from './usePolling'; export type { UsePollingOptions, UsePollingReturn } from './usePolling'; diff --git a/frontend/src/composables/useKeyRecorder.ts b/frontend/src/composables/useKeyRecorder.ts new file mode 100644 index 00000000..b13176ae --- /dev/null +++ b/frontend/src/composables/useKeyRecorder.ts @@ -0,0 +1,176 @@ +import { ref, onUnmounted, readonly, type Ref, type DeepReadonly } from 'vue'; + +export interface KeyRecorderState { + /** The binding ID currently being recorded */ + bindingId: number; + /** Internal key string result (e.g. "Mod-Shift-k") */ + result: string; + /** Display parts for UI (e.g. ["Ctrl", "Shift", "K"]) */ + displayParts: string[]; + /** Whether a non-modifier key has been captured (recording complete) */ + completed: boolean; +} + +export interface UseKeyRecorderOptions { + isMacOS: boolean; + onComplete?: (bindingId: number, keyString: string) => void; + onCancel?: (bindingId: number) => void; +} + +export interface UseKeyRecorderReturn { + recording: DeepReadonly>; + isRecording: (bindingId: number) => boolean; + startRecording: (bindingId: number) => void; + stopRecording: () => void; +} + +const MODIFIER_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta']); + +const KEY_DISPLAY_MAP: Record = { + 'ArrowUp': '↑', + 'ArrowDown': '↓', + 'ArrowLeft': '←', + 'ArrowRight': '→', + ' ': 'Space', +}; + +const MAC_MODIFIER_DISPLAY: Record = { + 'Mod': '⌘', + 'Alt': '⌥', + 'Shift': '⇧', + 'Ctrl': '⌃', +}; + +function normalizeKey(key: string): string { + if (key === ' ') return 'Space'; + if (key.length === 1) return key.toLowerCase(); + return key; +} + +function getDisplayKey(key: string, isMacOS: boolean): string { + if (isMacOS && MAC_MODIFIER_DISPLAY[key]) return MAC_MODIFIER_DISPLAY[key]; + if (KEY_DISPLAY_MAP[key]) return KEY_DISPLAY_MAP[key]; + if (key.length === 1) return key.toUpperCase(); + return key; +} + +/** + * Composable for capturing keyboard shortcuts via keydown events. + * Captures the full key combination (modifiers + main key) in a single interaction. + */ +export function useKeyRecorder(options: UseKeyRecorderOptions): UseKeyRecorderReturn { + const { isMacOS, onComplete, onCancel } = options; + + const recording = ref(null); + + const buildParts = (e: KeyboardEvent): { internalParts: string[]; displayParts: string[] } => { + const internalParts: string[] = []; + const displayParts: string[] = []; + + const hasCtrlOrMeta = e.ctrlKey || e.metaKey; + if (hasCtrlOrMeta) { + internalParts.push('Mod'); + displayParts.push(getDisplayKey('Mod', isMacOS)); + } + + if (e.altKey) { + internalParts.push('Alt'); + displayParts.push(getDisplayKey('Alt', isMacOS)); + } + + if (e.shiftKey) { + internalParts.push('Shift'); + displayParts.push(getDisplayKey('Shift', isMacOS)); + } + + return { internalParts, displayParts }; + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (!recording.value) return; + + e.preventDefault(); + e.stopPropagation(); + + const { internalParts, displayParts } = buildParts(e); + + if (MODIFIER_KEYS.has(e.key)) { + recording.value = { + ...recording.value, + result: '', + displayParts, + completed: false, + }; + return; + } + + const mainKey = normalizeKey(e.key); + internalParts.push(mainKey); + displayParts.push(getDisplayKey(e.key, isMacOS)); + + const keyString = internalParts.join('-'); + + recording.value = { + ...recording.value, + result: keyString, + displayParts, + completed: true, + }; + + const bindingId = recording.value.bindingId; + cleanup(); + recording.value = null; + onComplete?.(bindingId, keyString); + }; + + const onKeyUp = (e: KeyboardEvent) => { + if (!recording.value) return; + e.preventDefault(); + e.stopPropagation(); + }; + + const preventDefaults = (e: KeyboardEvent) => { + if (!recording.value) return; + e.preventDefault(); + e.stopPropagation(); + }; + + const cleanup = () => { + document.removeEventListener('keydown', onKeyDown, true); + document.removeEventListener('keyup', onKeyUp, true); + document.removeEventListener('keypress', preventDefaults, true); + }; + + const startRecording = (bindingId: number) => { + cleanup(); + + recording.value = { + bindingId, + result: '', + displayParts: [], + completed: false, + }; + + document.addEventListener('keydown', onKeyDown, true); + document.addEventListener('keyup', onKeyUp, true); + document.addEventListener('keypress', preventDefaults, true); + }; + + const stopRecording = () => { + cleanup(); + recording.value = null; + }; + + const isRecording = (bindingId: number): boolean => { + return recording.value?.bindingId === bindingId; + }; + + onUnmounted(cleanup); + + return { + recording: readonly(recording) as DeepReadonly>, + isRecording, + startRecording, + stopRecording, + }; +} diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index a06c61bc..56828c3b 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -101,10 +101,10 @@ export default { preventDefault: 'Prevent Default', keybinding: 'Keybinding' }, - keyPlaceholder: 'Enter key, press Enter to add', - invalidFormat: 'Invalid format', - conflict: 'Conflict: {command}', - maxKeysReached: 'Maximum 4 keys allowed', + saveFailed: 'Failed to save keybinding', + resetSingle: 'Reset to default', + confirmSave: 'Confirm save', + conflictWith: 'Conflicts with "{command}"', commands: { showSearch: 'Show search panel', hideSearch: 'Hide search panel', @@ -244,6 +244,117 @@ export default { customThemeColors: 'Custom Theme Colors', resetToDefault: 'Reset to Default', colorValue: 'Color Value', + themeColors: { + // Editor UI colors + background: 'Background', + backgroundSecondary: 'Secondary Background', + foreground: 'Foreground Text', + cursor: 'Cursor Color', + selection: 'Selection Highlight', + activeLine: 'Active Line Background', + lineNumber: 'Line Number', + activeLineNumber: 'Active Line Number', + diffInserted: 'Diff Inserted', + diffDeleted: 'Diff Deleted', + diffChanged: 'Diff Changed', + borderColor: 'Border Color', + matchingBracket: 'Matching Bracket', + searchMatch: 'Search Match Background', + searchMatchSelected: 'Selected Match Background', + searchMatchSelectedOutline: 'Selected Match Outline', + // Syntax - Comments + comment: 'Comment', + lineComment: 'Line Comment', + blockComment: 'Block Comment', + docComment: 'Doc Comment', + // Syntax - Names / Identifiers + name: 'Name', + variableName: 'Variable Name', + typeName: 'Type Name', + tagName: 'Tag Name', + propertyName: 'Property Name', + attributeName: 'Attribute Name', + className: 'Class Name', + labelName: 'Label Name', + namespace: 'Namespace', + macroName: 'Macro Name', + // Syntax - Literals + literal: 'Literal', + string: 'String', + docString: 'Doc String', + character: 'Character', + attributeValue: 'Attribute Value', + number: 'Number', + integer: 'Integer', + float: 'Float', + bool: 'Boolean', + regexp: 'Regular Expression', + escape: 'Escape Sequence', + color: 'Color Literal', + url: 'URL', + // Syntax - Keywords + keyword: 'Keyword', + self: 'Self / This', + null: 'Null / Nil', + atom: 'Atom', + unit: 'Unit', + modifier: 'Modifier', + operatorKeyword: 'Operator Keyword', + controlKeyword: 'Control Keyword', + definitionKeyword: 'Definition Keyword', + moduleKeyword: 'Module Keyword', + // Syntax - Operators + operator: 'Operator', + derefOperator: 'Dereference Operator', + arithmeticOperator: 'Arithmetic Operator', + logicOperator: 'Logic Operator', + bitwiseOperator: 'Bitwise Operator', + compareOperator: 'Comparison Operator', + updateOperator: 'Assignment Operator', + definitionOperator: 'Definition Operator', + typeOperator: 'Type Operator', + controlOperator: 'Control Operator', + // Syntax - Punctuation + punctuation: 'Punctuation', + separator: 'Separator', + bracket: 'Bracket', + angleBracket: 'Angle Bracket', + squareBracket: 'Square Bracket', + paren: 'Parenthesis', + brace: 'Brace', + // Syntax - Document / Content + content: 'Content', + heading: 'Heading', + heading1: 'Heading 1', + heading2: 'Heading 2', + heading3: 'Heading 3', + heading4: 'Heading 4', + heading5: 'Heading 5', + heading6: 'Heading 6', + contentSeparator: 'Content Separator', + list: 'List', + quote: 'Blockquote', + emphasis: 'Emphasis (Italic)', + strong: 'Strong (Bold)', + link: 'Link', + monospace: 'Monospace', + strikethrough: 'Strikethrough', + inserted: 'Inserted', + deleted: 'Deleted', + changed: 'Changed', + invalid: 'Invalid', + meta: 'Meta', + documentMeta: 'Document Meta', + annotation: 'Annotation', + processingInstruction: 'Processing Instruction', + // Syntax - Misc + definition: 'Definition', + constant: 'Constant', + function: 'Function', + standard: 'Standard', + local: 'Local Variable', + special: 'Special', + }, lineHeight: 'Line Height', tabSettings: 'Tab Settings', tabSize: 'Tab Size', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 1ea001d3..995c99cd 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -101,10 +101,10 @@ export default { preventDefault: '阻止默认', keybinding: '快捷键' }, - keyPlaceholder: '输入键名, 回车添加', - invalidFormat: '格式错误', - conflict: '冲突: {command}', - maxKeysReached: '最多只能添加4个键', + saveFailed: '保存快捷键失败', + resetSingle: '恢复默认', + confirmSave: '确认保存', + conflictWith: '与 "{command}" 冲突', commands: { showSearch: '显示搜索面板', hideSearch: '隐藏搜索面板', @@ -292,6 +292,117 @@ export default { customThemeColors: '自定义主题颜色', resetToDefault: '重置为默认', colorValue: '颜色值', + themeColors: { + // 编辑器 UI 颜色 + background: '背景色', + backgroundSecondary: '次要背景色', + foreground: '前景文字色', + cursor: '光标颜色', + selection: '选中高亮色', + activeLine: '当前行背景色', + lineNumber: '行号颜色', + activeLineNumber: '当前行号颜色', + diffInserted: '差异插入色', + diffDeleted: '差异删除色', + diffChanged: '差异变更色', + borderColor: '边框颜色', + matchingBracket: '匹配括号颜色', + searchMatch: '搜索匹配背景色', + searchMatchSelected: '选中匹配背景色', + searchMatchSelectedOutline: '选中匹配边框色', + // 语法高亮 - 注释 + comment: '注释', + lineComment: '行注释', + blockComment: '块注释', + docComment: '文档注释', + // 语法高亮 - 名称/标识符 + name: '名称', + variableName: '变量名', + typeName: '类型名', + tagName: '标签名', + propertyName: '属性名', + attributeName: '特性名', + className: '类名', + labelName: '标签标识符', + namespace: '命名空间', + macroName: '宏名', + // 语法高亮 - 字面量 + literal: '字面量', + string: '字符串', + docString: '文档字符串', + character: '字符', + attributeValue: '特性值', + number: '数字', + integer: '整数', + float: '浮点数', + bool: '布尔值', + regexp: '正则表达式', + escape: '转义字符', + color: '颜色字面量', + url: 'URL 链接', + // 语法高亮 - 关键字 + keyword: '关键字', + self: 'self/this', + null: 'null/空值', + atom: '原子', + unit: '单位', + modifier: '修饰符', + operatorKeyword: '运算符关键字', + controlKeyword: '控制关键字', + definitionKeyword: '定义关键字', + moduleKeyword: '模块关键字', + // 语法高亮 - 运算符 + operator: '运算符', + derefOperator: '解引用运算符', + arithmeticOperator: '算术运算符', + logicOperator: '逻辑运算符', + bitwiseOperator: '位运算符', + compareOperator: '比较运算符', + updateOperator: '赋值运算符', + definitionOperator: '定义运算符', + typeOperator: '类型运算符', + controlOperator: '控制运算符', + // 语法高亮 - 标点 + punctuation: '标点符号', + separator: '分隔符', + bracket: '括号', + angleBracket: '尖括号', + squareBracket: '方括号', + paren: '圆括号', + brace: '花括号', + // 语法高亮 - 文档/内容 + content: '内容', + heading: '标题', + heading1: '一级标题', + heading2: '二级标题', + heading3: '三级标题', + heading4: '四级标题', + heading5: '五级标题', + heading6: '六级标题', + contentSeparator: '内容分隔符', + list: '列表', + quote: '引用块', + emphasis: '强调(斜体)', + strong: '加粗', + link: '链接', + monospace: '等宽字体', + strikethrough: '删除线', + inserted: '插入标记', + deleted: '删除标记', + changed: '变更标记', + invalid: '无效语法', + meta: '元信息', + documentMeta: '文档元信息', + annotation: '注解', + processingInstruction: '处理指令', + // 语法高亮 - 其他 + definition: '定义', + constant: '常量', + function: '函数', + standard: '标准', + local: '局部变量', + special: '特殊', + }, hotkeyPreview: '预览:', none: '无', sync: { diff --git a/frontend/src/views/settings/pages/AppearancePage.vue b/frontend/src/views/settings/pages/AppearancePage.vue index 888539ac..590869ee 100644 --- a/frontend/src/views/settings/pages/AppearancePage.vue +++ b/frontend/src/views/settings/pages/AppearancePage.vue @@ -12,7 +12,7 @@ import { useConfirm } from '@/composables/useConfirm'; import PickColors from 'vue-pick-colors'; import type { ThemeColors } from '@/views/editor/theme/types'; -const { t } = useI18n(); +const { t, te } = useI18n(); const configStore = useConfigStore(); const themeStore = useThemeStore(); const editorStore = useEditorStore(); @@ -92,10 +92,13 @@ const colorKeys = computed(() => { }); const colorList = computed(() => - colorKeys.value.map(colorKey => ({ - key: colorKey, - label: colorKey - })) + colorKeys.value.map(colorKey => { + const i18nKey = `settings.themeColors.${colorKey}`; + return { + key: colorKey, + label: te(i18nKey) ? t(i18nKey) : colorKey, + }; + }) ); const colorSearch = ref(''); @@ -105,7 +108,10 @@ const searchInputRef = ref(null); const filteredColorList = computed(() => { const keyword = colorSearch.value.trim().toLowerCase(); if (!keyword) return colorList.value; - return colorList.value.filter(color => color.key.toLowerCase().includes(keyword)); + return colorList.value.filter(color => + color.key.toLowerCase().includes(keyword) || + color.label.toLowerCase().includes(keyword) + ); }); const toggleSearch = async () => { diff --git a/frontend/src/views/settings/pages/KeyBindingsPage.vue b/frontend/src/views/settings/pages/KeyBindingsPage.vue index 71212f94..4d1d4c8d 100644 --- a/frontend/src/views/settings/pages/KeyBindingsPage.vue +++ b/frontend/src/views/settings/pages/KeyBindingsPage.vue @@ -1,6 +1,6 @@