| 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 |