返回 DeepSeek-Reasonix
ResizableDrawer.tsx
根目录 / desktop / frontend / src / components / ResizableDrawer.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";
3 import { useT } from "../lib/i18n";
4 import { loadLayoutSize, saveLayoutSize, type LayoutSizeKey } from "../lib/layoutPreferences";
5 import { createRafResizeUpdater } from "../lib/resizeDrag";
6 import { useDeferredClose } from "../lib/useMountTransition";
7
8 const DRAWER_DEFAULT_WIDTH = 440;
9 const DRAWER_MIN_WIDTH = 360;
10 const DRAWER_MAX_WIDTH = 760;
11 const DRAWER_MAX_RATIO = 0.62;
12 const SETTINGS_DRAWER_DEFAULT_WIDTH = 720;
13 const SETTINGS_DRAWER_MIN_WIDTH = 620;
14 const SETTINGS_DRAWER_MAX_WIDTH = 1120;
15 const SETTINGS_DRAWER_MAX_RATIO = 0.82;
16
17 function drawerConfig(wide: boolean) {
18 return wide
19 ? {
20 key: "settingsDrawerWidth" as LayoutSizeKey,
21 defaultWidth: SETTINGS_DRAWER_DEFAULT_WIDTH,
22 minWidth: SETTINGS_DRAWER_MIN_WIDTH,
23 maxWidth: SETTINGS_DRAWER_MAX_WIDTH,
24 maxRatio: SETTINGS_DRAWER_MAX_RATIO,
25 }
26 : {
27 key: "drawerWidth" as LayoutSizeKey,
28 defaultWidth: DRAWER_DEFAULT_WIDTH,
29 minWidth: DRAWER_MIN_WIDTH,
30 maxWidth: DRAWER_MAX_WIDTH,
31 maxRatio: DRAWER_MAX_RATIO,
32 };
33 }
34
35 function clampDrawerWidth(width: number, wide: boolean, viewportWidth = 1440): number {
36 const config = drawerConfig(wide);
37 const maxByViewport = Math.floor(viewportWidth * config.maxRatio);
38 const max = Math.max(config.minWidth, Math.min(config.maxWidth, maxByViewport));
39 return Math.min(max, Math.max(config.minWidth, Math.round(width)));
40 }
41
42 export function ResizableDrawer({
43 children,
44 onClose,
45 subtle = false,
46 wide = false,
47 }: {
48 children: ReactNode;
49 onClose: () => void;
50 subtle?: boolean;
51 wide?: boolean;
52 }) {
53 const t = useT();
54 const config = drawerConfig(wide);
55 const drawerRef = useRef<HTMLElement>(null);
56 const [viewportWidth, setViewportWidth] = useState(() => (typeof window === "undefined" ? 1440 : window.innerWidth));
57 const [width, setWidth] = useState(() =>
58 loadLayoutSize(config.key, config.defaultWidth, (value) => clampDrawerWidth(value, wide)),
59 );
60 const [resizing, setResizing] = useState(false);
61 // Slide the drawer back out before the parent unmounts it.
62 const { status, requestClose } = useDeferredClose(onClose, 240);
63 const effectiveWidth = useMemo(() => clampDrawerWidth(width, wide, viewportWidth), [viewportWidth, wide, width]);
64 const style = useMemo(() => ({ "--drawer-width": `${effectiveWidth}px` }) as CSSProperties, [effectiveWidth]);
65
66 useEffect(() => {
67 const onResize = () => setViewportWidth(window.innerWidth);
68 window.addEventListener("resize", onResize);
69 return () => window.removeEventListener("resize", onResize);
70 }, []);
71
72 const saveWidth = useCallback(
73 (nextWidth: number) => {
74 const next = clampDrawerWidth(nextWidth, wide, viewportWidth);
75 setWidth(next);
76 saveLayoutSize(config.key, next);
77 },
78 [config.key, viewportWidth, wide],
79 );
80
81 const startResize = useCallback(
82 (event: ReactPointerEvent<HTMLButtonElement>) => {
83 if (event.button !== 0) return;
84 const drawer = drawerRef.current;
85 if (!drawer) return;
86 event.preventDefault();
87 setResizing(true);
88 let nextWidth = effectiveWidth;
89 const liveResize = createRafResizeUpdater({
90 target: drawer,
91 separator: event.currentTarget,
92 cssVar: "--drawer-width",
93 });
94 const onMove = (moveEvent: PointerEvent) => {
95 nextWidth = clampDrawerWidth(window.innerWidth - moveEvent.clientX, wide, window.innerWidth);
96 liveResize.schedule(nextWidth);
97 };
98 const onDone = () => {
99 liveResize.flush();
100 setWidth(nextWidth);
101 saveLayoutSize(config.key, nextWidth);
102 setResizing(false);
103 window.removeEventListener("pointermove", onMove);
104 window.removeEventListener("pointerup", onDone);
105 window.removeEventListener("pointercancel", onDone);
106 document.body.style.cursor = "";
107 document.body.style.userSelect = "";
108 };
109 document.body.style.cursor = "col-resize";
110 document.body.style.userSelect = "none";
111 window.addEventListener("pointermove", onMove);
112 window.addEventListener("pointerup", onDone);
113 window.addEventListener("pointercancel", onDone);
114 },
115 [config.key, effectiveWidth, wide],
116 );
117
118 const onKeyDown = useCallback(
119 (event: KeyboardEvent<HTMLButtonElement>) => {
120 if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
121 event.preventDefault();
122 saveWidth(effectiveWidth + (event.key === "ArrowLeft" ? 16 : -16));
123 } else if (event.key === "Home") {
124 event.preventDefault();
125 saveWidth(config.minWidth);
126 } else if (event.key === "End") {
127 event.preventDefault();
128 saveWidth(config.maxWidth);
129 }
130 },
131 [config.maxWidth, config.minWidth, effectiveWidth, saveWidth],
132 );
133
134 return (
135 <div className={`drawer-backdrop${subtle ? " drawer-backdrop--subtle" : ""}`} data-state={status} onClick={requestClose}>
136 <aside
137 ref={drawerRef}
138 className={`drawer${wide ? " drawer--wide" : ""}${resizing ? " drawer--resizing" : ""}`}
139 data-state={status}
140 onClick={(e) => e.stopPropagation()}
141 style={style}
142 >
143 <button
144 className="drawer-resizer"
145 type="button"
146 role="separator"
147 aria-orientation="vertical"
148 aria-label={t("drawer.resize")}
149 aria-valuemin={config.minWidth}
150 aria-valuemax={config.maxWidth}
151 aria-valuenow={effectiveWidth}
152 onPointerDown={startResize}
153 onKeyDown={onKeyDown}
154 onDoubleClick={() => saveWidth(config.defaultWidth)}
155 />
156 {children}
157 </aside>
158 </div>
159 );
160 }
161
161 lines Plain Text