返回 DeepSeek-Reasonix
StatusBarItemsEditor.tsx
根目录 / desktop / frontend / src / components / StatusBarItemsEditor.tsx
1 import { useEffect, useId, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent } from "react";
2 import { Check, ChevronDown, ChevronUp, EyeOff, GripVertical } from "lucide-react";
3 import { useT } from "../lib/i18n";
4 import { DEFAULT_STATUS_BAR_ITEMS, type StatusBarItemId } from "../lib/statusBarItems";
5 import { Tooltip } from "./Tooltip";
6
7 type DropPlacement = "before" | "after";
8 type DragTarget = { id: StatusBarItemId; placement: DropPlacement };
9
10 export function StatusBarItemsEditor({
11 items,
12 busy,
13 onChange,
14 itemLabel,
15 }: {
16 items: StatusBarItemId[];
17 busy: boolean;
18 onChange: (items: StatusBarItemId[]) => void;
19 itemLabel: (id: StatusBarItemId) => string;
20 }) {
21 const t = useT();
22 const [expanded, setExpanded] = useState(false);
23 const [draggingItem, setDraggingItem] = useState<StatusBarItemId | null>(null);
24 const [dragTarget, setDragTargetState] = useState<DragTarget | null>(null);
25 const [dropZone, setDropZoneState] = useState<"hidden" | null>(null);
26 const draggingItemRef = useRef<StatusBarItemId | null>(null);
27 const dragTargetRef = useRef<DragTarget | null>(null);
28 const dropZoneRef = useRef<"hidden" | null>(null);
29 const mouseDragCleanupRef = useRef<(() => void) | null>(null);
30 const panelId = useId();
31 const visibleItems = items;
32 const visibleSet = new Set<StatusBarItemId>(visibleItems);
33 const hiddenItems = DEFAULT_STATUS_BAR_ITEMS.filter((id) => !visibleSet.has(id));
34 const visiblePaneLabel = t("settings.statusBarItemsVisible", { count: visibleItems.length });
35 const hiddenPaneLabel = t("settings.statusBarItemsHidden", { count: hiddenItems.length });
36 const isDefault = visibleItems.length === DEFAULT_STATUS_BAR_ITEMS.length &&
37 visibleItems.every((id, index) => id === DEFAULT_STATUS_BAR_ITEMS[index]);
38
39 useEffect(() => () => mouseDragCleanupRef.current?.(), []);
40
41 const setDragTarget = (target: DragTarget | null) => {
42 const current = dragTargetRef.current;
43 if (current?.id === target?.id && current?.placement === target?.placement) return;
44 dragTargetRef.current = target;
45 setDragTargetState(target);
46 };
47 const setDropZone = (zone: "hidden" | null) => {
48 if (dropZoneRef.current === zone) return;
49 dropZoneRef.current = zone;
50 setDropZoneState(zone);
51 };
52 const itemFromPoint = (x: number, y: number): DragTarget | null => {
53 const row = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-statusbar-setting-item]");
54 const id = row?.dataset.statusbarSettingItem as StatusBarItemId | undefined;
55 if (!row || !id || !visibleItems.includes(id)) return null;
56 const rect = row.getBoundingClientRect();
57 return { id, placement: y < rect.top + rect.height / 2 ? "before" : "after" };
58 };
59 const hiddenZoneFromPoint = (x: number, y: number): boolean =>
60 document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-statusbar-drop-zone='hidden']") != null;
61 const reorderItem = (fromId: StatusBarItemId, toId: StatusBarItemId, placement: DropPlacement) => {
62 const fromIndex = visibleItems.indexOf(fromId);
63 const toIndex = visibleItems.indexOf(toId);
64 if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return;
65 const next = visibleItems.filter((item) => item !== fromId);
66 const insertAt = next.indexOf(toId);
67 if (insertAt < 0) return;
68 next.splice(placement === "after" ? insertAt + 1 : insertAt, 0, fromId);
69 if (!next.every((item, index) => item === visibleItems[index])) onChange(next);
70 };
71 const toggleItem = (id: StatusBarItemId) => {
72 if (visibleSet.has(id)) {
73 if (visibleItems.length > 1) onChange(visibleItems.filter((item) => item !== id));
74 return;
75 }
76 onChange([...visibleItems, id]);
77 };
78 const moveItem = (id: StatusBarItemId, direction: -1 | 1) => {
79 const index = visibleItems.indexOf(id);
80 const nextIndex = index + direction;
81 if (index < 0 || nextIndex < 0 || nextIndex >= visibleItems.length) return;
82 const next = [...visibleItems];
83 [next[index], next[nextIndex]] = [next[nextIndex], next[index]];
84 onChange(next);
85 };
86 const beginDrag = (id: StatusBarItemId): boolean => {
87 if (busy || !visibleSet.has(id)) return false;
88 mouseDragCleanupRef.current?.();
89 mouseDragCleanupRef.current = null;
90 draggingItemRef.current = id;
91 dragTargetRef.current = null;
92 dropZoneRef.current = null;
93 setDraggingItem(id);
94 setDragTargetState(null);
95 setDropZoneState(null);
96 return true;
97 };
98 const updateDrag = (clientX: number, clientY: number) => {
99 const draggingId = draggingItemRef.current;
100 if (!draggingId) return;
101 if (hiddenZoneFromPoint(clientX, clientY)) {
102 setDragTarget(null);
103 setDropZone("hidden");
104 return;
105 }
106 setDropZone(null);
107 const target = itemFromPoint(clientX, clientY);
108 setDragTarget(target && target.id !== draggingId ? target : null);
109 };
110 const finishDrag = (clientX?: number, clientY?: number) => {
111 const draggingId = draggingItemRef.current;
112 let target = dragTargetRef.current;
113 let zone = dropZoneRef.current;
114 if (draggingId && clientX !== undefined && clientY !== undefined) {
115 zone = hiddenZoneFromPoint(clientX, clientY) ? "hidden" : null;
116 const pointerTarget = zone ? null : itemFromPoint(clientX, clientY);
117 if (pointerTarget && pointerTarget.id !== draggingId) target = pointerTarget;
118 }
119 if (draggingId && zone === "hidden" && visibleItems.length > 1) {
120 onChange(visibleItems.filter((item) => item !== draggingId));
121 } else if (draggingId && target) {
122 reorderItem(draggingId, target.id, target.placement);
123 }
124 draggingItemRef.current = null;
125 dragTargetRef.current = null;
126 dropZoneRef.current = null;
127 setDraggingItem(null);
128 setDragTargetState(null);
129 setDropZoneState(null);
130 };
131 const cancelDrag = () => {
132 mouseDragCleanupRef.current?.();
133 mouseDragCleanupRef.current = null;
134 draggingItemRef.current = null;
135 dragTargetRef.current = null;
136 dropZoneRef.current = null;
137 setDraggingItem(null);
138 setDragTargetState(null);
139 setDropZoneState(null);
140 };
141 const startPointerDrag = (event: PointerEvent<HTMLElement>, id: StatusBarItemId) => {
142 if (event.button !== 0 || !beginDrag(id)) return;
143 event.preventDefault();
144 event.currentTarget.setPointerCapture(event.pointerId);
145 };
146 const movePointerDrag = (event: PointerEvent<HTMLElement>) => {
147 if (!draggingItemRef.current) return;
148 event.preventDefault();
149 updateDrag(event.clientX, event.clientY);
150 };
151 const endPointerDrag = (event: PointerEvent<HTMLElement>) => {
152 if (!draggingItemRef.current) return;
153 event.preventDefault();
154 try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { /* capture may already be released */ }
155 finishDrag(event.clientX, event.clientY);
156 };
157 const startMouseDrag = (event: ReactMouseEvent<HTMLElement>, id: StatusBarItemId) => {
158 if (event.button !== 0 || !beginDrag(id)) return;
159 event.preventDefault();
160 const handleMove = (moveEvent: MouseEvent) => {
161 moveEvent.preventDefault();
162 updateDrag(moveEvent.clientX, moveEvent.clientY);
163 };
164 const cleanup = () => {
165 window.removeEventListener("mousemove", handleMove);
166 window.removeEventListener("mouseup", handleUp);
167 };
168 const handleUp = (upEvent: MouseEvent) => {
169 upEvent.preventDefault();
170 cleanup();
171 mouseDragCleanupRef.current = null;
172 finishDrag(upEvent.clientX, upEvent.clientY);
173 };
174 window.addEventListener("mousemove", handleMove);
175 window.addEventListener("mouseup", handleUp);
176 mouseDragCleanupRef.current = cleanup;
177 };
178 const renderRow = (id: StatusBarItemId, visible: boolean, index: number) => {
179 const label = itemLabel(id);
180 const moveUpLabel = t("settings.statusBarItem.moveUp", { label });
181 const moveDownLabel = t("settings.statusBarItem.moveDown", { label });
182 const targetPlacement = dragTarget?.id === id ? dragTarget.placement : null;
183 return (
184 <div
185 className={[
186 "status-bar-item-row",
187 visible ? "" : "status-bar-item-row--hidden",
188 draggingItem === id ? "status-bar-item-row--dragging" : "",
189 targetPlacement ? "status-bar-item-row--drag-over" : "",
190 targetPlacement === "before" ? "status-bar-item-row--drop-before" : "",
191 targetPlacement === "after" ? "status-bar-item-row--drop-after" : "",
192 ].filter(Boolean).join(" ")}
193 data-statusbar-setting-item={id}
194 key={id}
195 >
196 <Tooltip label={t("settings.statusBarItem.drag", { label })}>
197 <button
198 type="button"
199 className="status-bar-item-row__drag"
200 disabled={!visible || busy}
201 aria-label={t("settings.statusBarItem.drag", { label })}
202 onPointerDown={(event) => startPointerDrag(event, id)}
203 onPointerMove={movePointerDrag}
204 onPointerUp={endPointerDrag}
205 onPointerCancel={() => cancelDrag()}
206 onMouseDown={(event) => startMouseDrag(event, id)}
207 >
208 <GripVertical size={14} aria-hidden="true" />
209 </button>
210 </Tooltip>
211 <span className="status-bar-item-row__position" aria-hidden="true">{visible ? index + 1 : "—"}</span>
212 <label className="status-bar-item-row__toggle">
213 <input
214 type="checkbox"
215 checked={visible}
216 disabled={busy || (visible && visibleItems.length <= 1)}
217 onChange={() => toggleItem(id)}
218 />
219 <span className="status-bar-item-row__check" aria-hidden="true">{visible && <Check size={12} />}</span>
220 <span className="status-bar-item-row__label">{label}</span>
221 </label>
222 <div className="status-bar-item-row__actions">
223 <Tooltip label={moveUpLabel}>
224 <button type="button" className="status-bar-item-row__order" disabled={busy || !visible || index <= 0} onClick={() => moveItem(id, -1)} aria-label={moveUpLabel}>
225 <ChevronUp size={14} aria-hidden="true" />
226 </button>
227 </Tooltip>
228 <Tooltip label={moveDownLabel}>
229 <button type="button" className="status-bar-item-row__order" disabled={busy || !visible || index >= visibleItems.length - 1} onClick={() => moveItem(id, 1)} aria-label={moveDownLabel}>
230 <ChevronDown size={14} aria-hidden="true" />
231 </button>
232 </Tooltip>
233 </div>
234 </div>
235 );
236 };
237
238 return (
239 <div className={`status-bar-items-editor${expanded ? " status-bar-items-editor--expanded" : ""}`}>
240 <div className="status-bar-items-editor__summary">
241 <span className="status-bar-items-editor__summary-text">
242 {t("settings.statusBarItemsSummary", { visible: visibleItems.length, total: DEFAULT_STATUS_BAR_ITEMS.length })}
243 </span>
244 <div className="status-bar-items-editor__summary-actions">
245 {expanded && (
246 <>
247 <button type="button" className="status-bar-items-editor__action status-bar-items-editor__action--accent" disabled={busy || hiddenItems.length === 0} onClick={() => onChange([...visibleItems, ...hiddenItems])}>
248 {t("settings.statusBarItemsShowAll")}
249 </button>
250 <button type="button" className="status-bar-items-editor__action" disabled={busy || isDefault} onClick={() => onChange([...DEFAULT_STATUS_BAR_ITEMS])}>
251 {t("settings.statusBarItemsRestoreDefault")}
252 </button>
253 </>
254 )}
255 <Tooltip label={t(expanded ? "settings.statusBarItemsCollapse" : "settings.statusBarItemsExpand")}>
256 <button type="button" className="status-bar-items-editor__toggle" aria-expanded={expanded} aria-controls={panelId} aria-label={t(expanded ? "settings.statusBarItemsCollapse" : "settings.statusBarItemsExpand")} onClick={() => setExpanded((open) => !open)}>
257 {expanded ? <ChevronUp size={15} aria-hidden="true" /> : <ChevronDown size={15} aria-hidden="true" />}
258 </button>
259 </Tooltip>
260 </div>
261 </div>
262 {expanded && (
263 <div className="status-bar-items-editor__workspace" id={panelId}>
264 <section className="status-bar-items-editor__pane status-bar-items-editor__pane--visible" aria-label={visiblePaneLabel}>
265 <div className="status-bar-items-editor__pane-title">{visiblePaneLabel}</div>
266 <div className="status-bar-items-editor__list">{visibleItems.map((id, index) => renderRow(id, true, index))}</div>
267 </section>
268 <section
269 className={`status-bar-items-editor__pane status-bar-items-editor__pane--hidden${dropZone === "hidden" ? " status-bar-items-editor__pane--drop-target" : ""}`}
270 aria-label={hiddenPaneLabel}
271 data-statusbar-drop-zone="hidden"
272 >
273 <div className="status-bar-items-editor__pane-title">{hiddenPaneLabel}</div>
274 {hiddenItems.length > 0 ? (
275 <div className="status-bar-items-editor__list">{hiddenItems.map((id, index) => renderRow(id, false, index))}</div>
276 ) : (
277 <div className="status-bar-items-editor__empty" role="status">
278 <EyeOff size={28} strokeWidth={1.5} aria-hidden="true" />
279 <strong>{t("settings.statusBarItemsHiddenEmpty")}</strong>
280 <span>{t("settings.statusBarItemsHiddenEmptyHint")}</span>
281 </div>
282 )}
283 </section>
284 </div>
285 )}
286 </div>
287 );
288 }
289
289 lines Plain Text