返回 JoyAI-Echo
SplitPane.tsx
1 import {
2 type PointerEvent as ReactPointerEvent,
3 type ReactNode,
4 useCallback,
5 useEffect,
6 useState,
7 } from "react";
8
9 import { cn } from "@/lib/utils";
10
11 const STORAGE_KEY = "nanobot-webui.split-ratio";
12 const DEFAULT_RATIO = 0.45;
13 const MIN_LEFT_PX = 360;
14 const MIN_RIGHT_PX = 420;
15
16 function readRatio(): number {
17 try {
18 const raw = localStorage.getItem(STORAGE_KEY);
19 if (!raw) return DEFAULT_RATIO;
20 const n = Number(raw);
21 return Number.isFinite(n)
22 ? Math.min(0.7, Math.max(0.25, n))
23 : DEFAULT_RATIO;
24 } catch {
25 return DEFAULT_RATIO;
26 }
27 }
28
29 interface SplitPaneProps {
30 left: ReactNode;
31 right: ReactNode;
32 className?: string;
33 }
34
35 export function SplitPane({ left, right, className }: SplitPaneProps) {
36 const [ratio, setRatio] = useState(readRatio);
37 const [dragging, setDragging] = useState(false);
38
39 useEffect(() => {
40 try {
41 localStorage.setItem(STORAGE_KEY, String(ratio));
42 } catch {}
43 }, [ratio]);
44
45 const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
46 e.preventDefault();
47 setDragging(true);
48 const container = (e.currentTarget as HTMLElement).parentElement!;
49 const rect = container.getBoundingClientRect();
50
51 const onMove = (me: PointerEvent) => {
52 const x = me.clientX - rect.left;
53 const total = rect.width;
54 const leftPx = Math.max(MIN_LEFT_PX, Math.min(total - MIN_RIGHT_PX, x));
55 setRatio(leftPx / total);
56 };
57 const onUp = () => {
58 setDragging(false);
59 window.removeEventListener("pointermove", onMove);
60 window.removeEventListener("pointerup", onUp);
61 };
62 window.addEventListener("pointermove", onMove);
63 window.addEventListener("pointerup", onUp);
64 }, []);
65
66 const pct = `${(ratio * 100).toFixed(2)}%`;
67
68 return (
69 <div
70 className={cn(
71 "relative flex h-full min-h-0 w-full overflow-hidden",
72 className,
73 )}
74 style={dragging ? { userSelect: "none" } : undefined}
75 >
76 <div
77 className="flex h-full min-w-0 flex-col overflow-hidden"
78 style={{ width: right ? pct : "100%" }}
79 >
80 {left}
81 </div>
82
83 {/* divider */}
84 {right ? (
85 <div
86 className="group/divider relative z-10 w-3 shrink-0 cursor-col-resize"
87 onPointerDown={onPointerDown}
88 >
89 <div
90 className={cn(
91 "absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors duration-200",
92 dragging ? "bg-foreground/20" : "bg-border/70",
93 )}
94 />
95 <div
96 className={cn(
97 "absolute left-1/2 top-1/2 h-10 w-1 -translate-x-1/2 -translate-y-1/2 rounded-full transition-all duration-200",
98 dragging
99 ? "bg-foreground/30 shadow-sm"
100 : "bg-border/80 group-hover/divider:bg-foreground/20 group-hover/divider:shadow-sm",
101 )}
102 />
103 </div>
104 ) : null}
105
106 <div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
107 {right}
108 </div>
109 </div>
110 );
111 }
112
112 lines Plain Text