Skip to content

Commit b1d290d

Browse files
committed
feat: add drag-to-reorder plugin icons in sidebar
Use @dnd-kit/core and @dnd-kit/sortable to support drag-and-drop reordering of plugin icons in SideNav. Long-press (300ms delay) to initiate drag; dropping fires onReorder with the new ordered plugin IDs.
1 parent 5339e08 commit b1d290d

3 files changed

Lines changed: 115 additions & 25 deletions

File tree

src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ function App() {
236236
selectedPlugin={selectedPlugin}
237237
onPluginContextAction={handlePluginContextAction}
238238
isPluginRefreshAvailable={isPluginRefreshAvailable}
239+
onNavReorder={handleReorder}
239240
appContentProps={{
240241
onRetryPlugin: handleRetryPlugin,
241242
onReorder: handleReorder,

src/components/app/app-shell.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type AppShellProps = {
2020
selectedPlugin: DisplayPluginState | null
2121
onPluginContextAction: (pluginId: string, action: PluginContextAction) => void
2222
isPluginRefreshAvailable: (pluginId: string) => boolean
23+
onNavReorder: (orderedIds: string[]) => void
2324
appContentProps: AppContentActionProps
2425
}
2526

@@ -32,6 +33,7 @@ export function AppShell({
3233
selectedPlugin,
3334
onPluginContextAction,
3435
isPluginRefreshAvailable,
36+
onNavReorder,
3537
appContentProps,
3638
}: AppShellProps) {
3739
const {
@@ -78,6 +80,7 @@ export function AppShell({
7880
plugins={navPlugins}
7981
onPluginContextAction={onPluginContextAction}
8082
isPluginRefreshAvailable={isPluginRefreshAvailable}
83+
onReorder={onNavReorder}
8184
/>
8285
<div className="flex-1 flex flex-col px-3 pt-2 pb-1.5 min-w-0 bg-card dark:bg-muted/50">
8386
<div className="relative flex-1 min-h-0">

src/components/side-nav.tsx

Lines changed: 111 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@ import { CircleHelp, Settings } from "lucide-react"
33
import { openUrl } from "@tauri-apps/plugin-opener"
44
import { invoke } from "@tauri-apps/api/core"
55
import { 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

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

3753
interface 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+
73142
export 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

Comments
 (0)