@@ -3,6 +3,21 @@ import { CircleHelp, Settings } from "lucide-react"
33import { openUrl } from "@tauri-apps/plugin-opener"
44import { invoke } from "@tauri-apps/api/core"
55import { Menu , MenuItem , PredefinedMenuItem } from "@tauri-apps/api/menu"
6+ import {
7+ DndContext ,
8+ closestCenter ,
9+ PointerSensor ,
10+ useSensor ,
11+ useSensors ,
12+ type DragEndEvent ,
13+ } from "@dnd-kit/core"
14+ import {
15+ arrayMove ,
16+ SortableContext ,
17+ useSortable ,
18+ verticalListSortingStrategy ,
19+ } from "@dnd-kit/sortable"
20+ import { CSS } from "@dnd-kit/utilities"
621
722function GaugeIcon ( { className } : { className ?: string } ) {
823 return (
@@ -32,6 +47,7 @@ interface SideNavProps {
3247 plugins : NavPlugin [ ]
3348 onPluginContextAction ?: ( pluginId : string , action : PluginContextAction ) => void
3449 isPluginRefreshAvailable ?: ( pluginId : string ) => boolean
50+ onReorder ?: ( orderedIds : string [ ] ) => void
3551}
3652
3753interface NavButtonProps {
@@ -70,15 +86,90 @@ function getIconColor(brandColor: string | undefined, isDark: boolean): string {
7086 return brandColor
7187}
7288
89+ interface SortableNavPluginProps {
90+ plugin : NavPlugin
91+ isActive : boolean
92+ isDark : boolean
93+ onClick : ( ) => void
94+ onContextMenu : ( e : React . MouseEvent ) => void
95+ }
96+
97+ function SortableNavPlugin ( { plugin, isActive, isDark, onClick, onContextMenu } : SortableNavPluginProps ) {
98+ const {
99+ attributes,
100+ listeners,
101+ setNodeRef,
102+ transform,
103+ transition,
104+ isDragging,
105+ } = useSortable ( { id : plugin . id } )
106+
107+ const style = {
108+ transform : CSS . Transform . toString ( transform ) ,
109+ transition,
110+ opacity : isDragging ? 0.5 : undefined ,
111+ }
112+
113+ return (
114+ < div ref = { setNodeRef } style = { style } { ...attributes } { ...listeners } >
115+ < NavButton
116+ isActive = { isActive }
117+ onClick = { onClick }
118+ onContextMenu = { onContextMenu }
119+ aria-label = { plugin . name }
120+ >
121+ < span
122+ role = "img"
123+ aria-label = { plugin . name }
124+ className = "size-6 inline-block"
125+ style = { {
126+ backgroundColor : getIconColor ( plugin . brandColor , isDark ) ,
127+ WebkitMaskImage : `url(${ plugin . iconUrl } )` ,
128+ WebkitMaskSize : "contain" ,
129+ WebkitMaskRepeat : "no-repeat" ,
130+ WebkitMaskPosition : "center" ,
131+ maskImage : `url(${ plugin . iconUrl } )` ,
132+ maskSize : "contain" ,
133+ maskRepeat : "no-repeat" ,
134+ maskPosition : "center" ,
135+ } }
136+ />
137+ </ NavButton >
138+ </ div >
139+ )
140+ }
141+
73142export function SideNav ( {
74143 activeView,
75144 onViewChange,
76145 plugins,
77146 onPluginContextAction,
78147 isPluginRefreshAvailable,
148+ onReorder,
79149} : SideNavProps ) {
80150 const isDark = useDarkMode ( )
81151
152+ const sensors = useSensors (
153+ useSensor ( PointerSensor , {
154+ activationConstraint : { delay : 300 , tolerance : 5 } ,
155+ } )
156+ )
157+
158+ const handleDragEnd = useCallback (
159+ ( event : DragEndEvent ) => {
160+ if ( ! onReorder ) return
161+ const { active, over } = event
162+ if ( over && active . id !== over . id ) {
163+ const oldIndex = plugins . findIndex ( ( p ) => p . id === active . id )
164+ const newIndex = plugins . findIndex ( ( p ) => p . id === over . id )
165+ if ( oldIndex === - 1 || newIndex === - 1 ) return
166+ const next = arrayMove ( plugins , oldIndex , newIndex )
167+ onReorder ( next . map ( ( p ) => p . id ) )
168+ }
169+ } ,
170+ [ onReorder , plugins ]
171+ )
172+
82173 const handlePluginContextMenu = useCallback (
83174 ( e : React . MouseEvent , pluginId : string ) => {
84175 e . preventDefault ( )
@@ -135,32 +226,27 @@ export function SideNav({
135226 </ NavButton >
136227
137228 { /* Plugin icons */ }
138- { plugins . map ( ( plugin ) => (
139- < NavButton
140- key = { plugin . id }
141- isActive = { activeView === plugin . id }
142- onClick = { ( ) => onViewChange ( plugin . id ) }
143- onContextMenu = { ( e ) => handlePluginContextMenu ( e , plugin . id ) }
144- aria-label = { plugin . name }
229+ < DndContext
230+ sensors = { sensors }
231+ collisionDetection = { closestCenter }
232+ onDragEnd = { handleDragEnd }
233+ >
234+ < SortableContext
235+ items = { plugins . map ( ( p ) => p . id ) }
236+ strategy = { verticalListSortingStrategy }
145237 >
146- < span
147- role = "img"
148- aria-label = { plugin . name }
149- className = "size-6 inline-block"
150- style = { {
151- backgroundColor : getIconColor ( plugin . brandColor , isDark ) ,
152- WebkitMaskImage : `url(${ plugin . iconUrl } )` ,
153- WebkitMaskSize : "contain" ,
154- WebkitMaskRepeat : "no-repeat" ,
155- WebkitMaskPosition : "center" ,
156- maskImage : `url(${ plugin . iconUrl } )` ,
157- maskSize : "contain" ,
158- maskRepeat : "no-repeat" ,
159- maskPosition : "center" ,
160- } }
161- />
162- </ NavButton >
163- ) ) }
238+ { plugins . map ( ( plugin ) => (
239+ < SortableNavPlugin
240+ key = { plugin . id }
241+ plugin = { plugin }
242+ isActive = { activeView === plugin . id }
243+ isDark = { isDark }
244+ onClick = { ( ) => onViewChange ( plugin . id ) }
245+ onContextMenu = { ( e ) => handlePluginContextMenu ( e , plugin . id ) }
246+ />
247+ ) ) }
248+ </ SortableContext >
249+ </ DndContext >
164250
165251 { /* Spacer */ }
166252 < div className = "flex-1" />
0 commit comments