155 lines
4.7 KiB
TypeScript
155 lines
4.7 KiB
TypeScript
"use client";
|
||
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import { useSearchParams } from "next/navigation";
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
|
||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||
import { Toaster } from "@/components/ui/sonner";
|
||
import { CommandPalette } from "@/components/workspace/command-palette";
|
||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||
import { getLocalSettings, useLocalSettings } from "@/core/settings";
|
||
|
||
const queryClient = new QueryClient();
|
||
|
||
export default function WorkspaceLayout({
|
||
children,
|
||
}: Readonly<{ children: React.ReactNode }>) {
|
||
const [settings, setSettings] = useLocalSettings();
|
||
const [open, setOpen] = useState(false); // SSR default: open (matches server render)
|
||
const [showWorkspaceSidebar, setShowWorkspaceSidebar] = useState(false);
|
||
const pressedKeysRef = useRef<Set<string>>(new Set());
|
||
const comboTriggeredRef = useRef(false);
|
||
const searchParams = useSearchParams();
|
||
|
||
// iframe 技能模式(mode=skill)时隐藏侧边栏
|
||
const isSkillMode = searchParams.get("mode") === "skill";
|
||
|
||
useLayoutEffect(() => {
|
||
// Runs synchronously before first paint on the client — no visual flash
|
||
setOpen(!getLocalSettings().layout.sidebar_collapsed);
|
||
}, []);
|
||
useEffect(() => {
|
||
setOpen(!settings.layout.sidebar_collapsed);
|
||
}, [settings.layout.sidebar_collapsed]);
|
||
|
||
useEffect(() => {
|
||
const resetComboTrigger = () => {
|
||
comboTriggeredRef.current = false;
|
||
};
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
const target = event.target as HTMLElement | null;
|
||
if (
|
||
target instanceof HTMLInputElement ||
|
||
target instanceof HTMLTextAreaElement ||
|
||
target?.isContentEditable
|
||
) {
|
||
return;
|
||
}
|
||
|
||
pressedKeysRef.current.add(event.key.toLowerCase());
|
||
|
||
const hasCtrlOrMeta = event.ctrlKey || event.metaKey;
|
||
const hasShift = event.shiftKey;
|
||
const hasL = pressedKeysRef.current.has("l");
|
||
const hasD = pressedKeysRef.current.has("d");
|
||
|
||
if (
|
||
hasCtrlOrMeta &&
|
||
hasShift &&
|
||
hasL &&
|
||
hasD &&
|
||
!comboTriggeredRef.current
|
||
) {
|
||
event.preventDefault();
|
||
comboTriggeredRef.current = true;
|
||
setShowWorkspaceSidebar((prev) => !prev);
|
||
}
|
||
};
|
||
|
||
const handleKeyUp = (event: KeyboardEvent) => {
|
||
pressedKeysRef.current.delete(event.key.toLowerCase());
|
||
if (
|
||
!pressedKeysRef.current.has("l") ||
|
||
!pressedKeysRef.current.has("d") ||
|
||
(!event.ctrlKey && !event.metaKey) ||
|
||
!event.shiftKey
|
||
) {
|
||
resetComboTrigger();
|
||
}
|
||
};
|
||
|
||
const handleBlur = () => {
|
||
pressedKeysRef.current.clear();
|
||
resetComboTrigger();
|
||
};
|
||
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
window.addEventListener("keyup", handleKeyUp);
|
||
window.addEventListener("blur", handleBlur);
|
||
return () => {
|
||
window.removeEventListener("keydown", handleKeyDown);
|
||
window.removeEventListener("keyup", handleKeyUp);
|
||
window.removeEventListener("blur", handleBlur);
|
||
};
|
||
}, []);
|
||
|
||
const handleOpenChange = useCallback(
|
||
(open: boolean) => {
|
||
setOpen(open);
|
||
setSettings("layout", { sidebar_collapsed: !open });
|
||
},
|
||
[setSettings],
|
||
);
|
||
return (
|
||
<QueryClientProvider client={queryClient}>
|
||
<SidebarProvider
|
||
className="h-screen"
|
||
open={open}
|
||
onOpenChange={handleOpenChange}
|
||
>
|
||
{!isSkillMode && showWorkspaceSidebar && (
|
||
<WorkspaceSidebar className="" />
|
||
)}
|
||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||
</SidebarProvider>
|
||
<CommandPalette />
|
||
<Toaster
|
||
position="top-center"
|
||
toastOptions={{
|
||
duration: 2200,
|
||
classNames: {
|
||
toast: [
|
||
/* 灰色圆角矩形容器 */
|
||
"rounded-[20px] border-none",
|
||
/* 浅灰色背景 + 轻微透明 */
|
||
"bg-[#999999]! backdrop-blur-sm",
|
||
/* 阴影极轻 */
|
||
"shadow-[0_2px_12px_0_rgba(0,0,0,0.18)]",
|
||
/* 内边距:宽松居中 */
|
||
"px-5 py-2.5",
|
||
/* 单行布局,内容水平居中 */
|
||
"flex items-center justify-center gap-0",
|
||
/* 整体文字样式 */
|
||
"text-white text-sm font-normal font-sans",
|
||
/* 去掉 icon 区域间距 */
|
||
"[&>[data-icon]]:hidden",
|
||
].join(" "),
|
||
title:
|
||
"text-white! text-sm font-normal text-center w-full leading-snug",
|
||
description: "hidden",
|
||
icon: "hidden",
|
||
},
|
||
}}
|
||
/>
|
||
</QueryClientProvider>
|
||
);
|
||
}
|