feat: 添加 ESC 键快捷返回功能 (#670)

* feat: 添加 ESC 键快捷返回功能

- FullScreenPanel 组件支持 ESC 键关闭
- App.tsx 主页面支持 ESC 键返回主界面
- 优化键盘事件处理,合并多个监听器
- 使用事件捕获阶段避免冲突
- 适用于所有子页面:MCP、设置、Prompts、Skills 等
- 跨平台兼容:macOS、Windows、Linux

* perf: 优化 ESC 键处理逻辑

- 使用 useRef 避免闭包陷阱,提升性能
- 修复输入框中按 ESC 会关闭面板的问题
- 检测焦点元素,不干扰输入框的 ESC 行为
- 改进用户体验,避免意外关闭导致数据丢失

* fix: enhance global keyboard shortcuts and improve useModelState sync

- App & FullScreenPanel: Use `isTextEditableTarget` to prevent shortcuts (ESC, etc.) from triggering while editing text.
- useModelState: Prevent overwriting user input during config synchronization.
- App: Add `Cmd/Ctrl + ,` shortcut to open settings.
- Add `isTextEditableTarget` utility.
This commit is contained in:
咸蛋黄
2026-01-20 16:33:50 +08:00
committed by GitHub
parent 76897e2b97
commit 7bb458eecb
4 changed files with 91 additions and 6 deletions
+28 -5
View File
@@ -30,6 +30,7 @@ import { useProviderActions } from "@/hooks/useProviderActions";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useLastValidValue } from "@/hooks/useLastValidValue";
import { extractErrorMessage } from "@/utils/errorUtils";
import { isTextEditableTarget } from "@/utils/domUtils";
import { cn } from "@/lib/utils";
import { isWindows, isLinux } from "@/lib/platform";
import { AppSwitcher } from "@/components/AppSwitcher";
@@ -291,18 +292,40 @@ function App() {
checkEnvOnSwitch();
}, [activeApp]);
// 全局键盘快捷键
const currentViewRef = useRef(currentView);
useEffect(() => {
const handleGlobalShortcut = (event: KeyboardEvent) => {
if (event.key !== "," || !(event.metaKey || event.ctrlKey)) {
currentViewRef.current = currentView;
}, [currentView]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Cmd/Ctrl + , 打开设置
if (event.key === "," && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
setCurrentView("settings");
return;
}
// ESC 键返回
if (event.key !== "Escape" || event.defaultPrevented) return;
// 如果有模态框打开(通过 overflow hidden 判断),则不处理全局 ESC,交给模态框处理
if (document.body.style.overflow === "hidden") return;
const view = currentViewRef.current;
if (view === "providers") return;
if (isTextEditableTarget(event.target)) return;
event.preventDefault();
setCurrentView("settings");
setCurrentView(view === "skillsDiscovery" ? "skills" : "providers");
};
window.addEventListener("keydown", handleGlobalShortcut);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleGlobalShortcut);
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
+34
View File
@@ -4,6 +4,7 @@ import { motion, AnimatePresence } from "framer-motion";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { isWindows, isLinux } from "@/lib/platform";
import { isTextEditableTarget } from "@/utils/domUtils";
interface FullScreenPanelProps {
isOpen: boolean;
@@ -37,6 +38,39 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
};
}, [isOpen]);
// ESC 键关闭面板
const onCloseRef = React.useRef(onClose);
React.useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
React.useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
// 子组件(例如 Radix 的 Select/Dialog/Dropdown)如果已经消费了 ESC,就不要再关闭整个面板
if (event.defaultPrevented) {
return;
}
if (isTextEditableTarget(event.target)) {
return; // 让输入框自己处理 ESC(比如清空、失焦等)
}
event.stopPropagation(); // 阻止事件继续冒泡到 window,避免触发 App.tsx 的全局监听
onCloseRef.current();
}
};
// 使用冒泡阶段监听,让子组件(如 Radix UI)优先处理 ESC
window.addEventListener("keydown", handleKeyDown, false);
return () => {
window.removeEventListener("keydown", handleKeyDown, false);
};
}, [isOpen]);
return createPortal(
<AnimatePresence>
{isOpen && (
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
interface UseModelStateProps {
settingsConfig: string;
@@ -19,12 +19,27 @@ export function useModelState({
const [defaultSonnetModel, setDefaultSonnetModel] = useState("");
const [defaultOpusModel, setDefaultOpusModel] = useState("");
const isUserEditingRef = useRef(false);
const lastConfigRef = useRef(settingsConfig);
// 初始化读取:读新键;若缺失,按兼容优先级回退
// Haiku: DEFAULT_HAIKU || SMALL_FAST || MODEL
// Sonnet: DEFAULT_SONNET || MODEL || SMALL_FAST
// Opus: DEFAULT_OPUS || MODEL || SMALL_FAST
// 仅在 settingsConfig 变化时同步一次(表单加载/切换预设时)
useEffect(() => {
if (lastConfigRef.current === settingsConfig) {
return;
}
if (isUserEditingRef.current) {
isUserEditingRef.current = false;
lastConfigRef.current = settingsConfig;
return;
}
lastConfigRef.current = settingsConfig;
try {
const cfg = settingsConfig ? JSON.parse(settingsConfig) : {};
const env = cfg?.env || {};
@@ -71,6 +86,8 @@ export function useModelState({
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
value: string,
) => {
isUserEditingRef.current = true;
if (field === "ANTHROPIC_MODEL") setClaudeModel(value);
if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value);
if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL")
+11
View File
@@ -0,0 +1,11 @@
export function isTextEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName;
return (
tagName === "INPUT" ||
tagName === "TEXTAREA" ||
tagName === "SELECT" ||
target.isContentEditable
);
}