mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
+28
-5
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user