From 34a0908d4f367645bca47389fd97fd504ad9fcdd Mon Sep 17 00:00:00 2001 From: suu <46421931+0suu@users.noreply.github.com> Date: Thu, 27 Nov 2025 02:41:00 +0900 Subject: [PATCH] Use dnd-kit for sortable lists with wheel scroll --- package.json | 3 + .../src/components/DeviceListScreen.tsx | 245 ++++++---- src/renderer/src/components/ScenesScreen.tsx | 437 ++++++++++++------ 3 files changed, 445 insertions(+), 240 deletions(-) diff --git a/package.json b/package.json index a453ee0..e336c16 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,9 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^7.1.0", + "@dnd-kit/utilities": "^3.2.2", "@mui/icons-material": "^7.3.5", "@mui/material": "^7.1.1", "@reduxjs/toolkit": "^2.8.2", diff --git a/src/renderer/src/components/DeviceListScreen.tsx b/src/renderer/src/components/DeviceListScreen.tsx index 38ecec9..965ff75 100644 --- a/src/renderer/src/components/DeviceListScreen.tsx +++ b/src/renderer/src/components/DeviceListScreen.tsx @@ -9,6 +9,23 @@ import CircularProgress from "@mui/material/CircularProgress"; import Alert from "@mui/material/Alert"; import IconButton from "@mui/material/IconButton"; import { motion, AnimatePresence } from "framer-motion"; +import { + DndContext, + DragEndEvent, + DragOverEvent, + DragStartEvent, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + rectSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { AppDispatch } from "../store/store"; import { @@ -68,6 +85,11 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) => const [reorderList, setReorderList] = useState([]); const [draggingId, setDraggingId] = useState(null); const [dragOverId, setDragOverId] = useState(null); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }) + ); const orderedDevices = useMemo(() => { const idToDevice = new Map(); @@ -163,66 +185,66 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) => // Enable wheel scrolling during drag useEffect(() => { - if (!draggingId) return; + if (!draggingId || !isReorderMode) return; const handleWheel = (e: WheelEvent) => { - // Allow default wheel behavior (scrolling) during drag window.scrollBy(0, e.deltaY); }; - window.addEventListener('wheel', handleWheel, { passive: true }); + window.addEventListener("wheel", handleWheel, { passive: true }); return () => { - window.removeEventListener('wheel', handleWheel); + window.removeEventListener("wheel", handleWheel); }; - }, [draggingId]); - - const handleReorder = (visibleOrder: string[]) => { - setReorderList(visibleOrder); - }; - - const moveIdInList = (list: string[], fromId: string, toId: string) => { - if (fromId === toId) return list; - const fromIndex = list.indexOf(fromId); - const toIndex = list.indexOf(toId); - if (fromIndex === -1 || toIndex === -1) return list; - const next = [...list]; - next.splice(fromIndex, 1); - next.splice(toIndex, 0, fromId); - return next; - }; + }, [draggingId, isReorderMode]); - const handleDragStart = (id: string) => (event: React.DragEvent) => { - setDraggingId(id); + const handleDragStart = (event: DragStartEvent) => { + if (!isReorderMode) return; + setDraggingId(event.active.id as string); setDragOverId(null); - event.dataTransfer.effectAllowed = "move"; }; - const handleDragOver = (overId: string) => (event: React.DragEvent) => { - event.preventDefault(); - if (!draggingId || draggingId === overId) return; - - // Only track hover state, don't update the list during drag + const handleDragOver = (event: DragOverEvent) => { + if (!isReorderMode) return; + const overId = event.over?.id as string | undefined; + if (!overId || overId === draggingId) { + setDragOverId(null); + return; + } setDragOverId(overId); }; - const handleDragLeave = () => { - setDragOverId(null); - }; + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!isReorderMode) { + setDraggingId(null); + setDragOverId(null); + return; + } - const handleDrop = (overId: string) => (event: React.DragEvent) => { - event.preventDefault(); + setDraggingId(null); - if (draggingId && overId && draggingId !== overId) { - // Update the list only once on drop - setReorderList((prev) => moveIdInList(prev, draggingId, overId)); + if (!over) { + setDragOverId(null); + return; } - setDraggingId(null); + const activeId = active.id as string; + const overId = over.id as string; + setDragOverId(null); + + if (activeId !== overId) { + setReorderList((prev) => { + const oldIndex = prev.indexOf(activeId); + const newIndex = prev.indexOf(overId); + if (oldIndex === -1 || newIndex === -1) return prev; + return arrayMove(prev, oldIndex, newIndex); + }); + } }; - const handleDragEnd = () => { + const handleDragCancel = () => { setDraggingId(null); setDragOverId(null); }; @@ -250,6 +272,57 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) => } }; + const SortableDeviceCard: React.FC<{ device: AnyDevice }> = ({ device }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({ + id: device.deviceId, + disabled: !isReorderMode, + }); + + return ( + + {isReorderMode && ( + + + + )} + + + ); + }; + if (!apiTokenSet) { return ( @@ -322,64 +395,52 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) => )} - - - - {(isReorderMode - ? reorderList - .map((deviceId) => filteredDeviceMap.get(deviceId)) - .filter((device): device is AnyDevice => !!device) - : filteredDevices - ).map((device) => ( - - - {isReorderMode && ( - + + + {isReorderMode ? ( + + {reorderList + .map((deviceId) => filteredDeviceMap.get(deviceId)) + .filter((device): device is AnyDevice => !!device) + .map((device) => ( + + + + ))} + + ) : ( + filteredDevices.map((device) => ( + + - - - )} - - - - ))} - - - + + + + )) + )} + + + + ); }; diff --git a/src/renderer/src/components/ScenesScreen.tsx b/src/renderer/src/components/ScenesScreen.tsx index 033a1dc..d76179b 100644 --- a/src/renderer/src/components/ScenesScreen.tsx +++ b/src/renderer/src/components/ScenesScreen.tsx @@ -11,6 +11,23 @@ import IconButton from "@mui/material/IconButton"; import SwapVertIcon from "@mui/icons-material/SwapVert"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import { motion, AnimatePresence } from "framer-motion"; +import { + DndContext, + DragEndEvent, + DragOverEvent, + DragStartEvent, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + rectSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { AppDispatch, RootState } from "../store/store"; import { useTranslation } from "../useTranslation"; @@ -68,6 +85,11 @@ export const ScenesScreen: React.FC = () => { const [reorderList, setReorderList] = useState([]); const [draggingId, setDraggingId] = useState(null); const [dragOverId, setDragOverId] = useState(null); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }) + ); const orderedScenes: SceneSummary[] = useMemo(() => { const idToScene = new Map(); @@ -134,7 +156,7 @@ export const ScenesScreen: React.FC = () => { }, [orderedScenes, isReorderMode]); useEffect(() => { - if (!draggingId) return; + if (!draggingId || !isReorderMode) return; const handleWheel = (e: WheelEvent) => { window.scrollBy(0, e.deltaY); @@ -145,49 +167,55 @@ export const ScenesScreen: React.FC = () => { return () => { window.removeEventListener("wheel", handleWheel); }; - }, [draggingId]); - - const moveIdInList = (list: string[], fromId: string, toId: string) => { - if (fromId === toId) return list; - const fromIndex = list.indexOf(fromId); - const toIndex = list.indexOf(toId); - if (fromIndex === -1 || toIndex === -1) return list; - const next = [...list]; - next.splice(fromIndex, 1); - next.splice(toIndex, 0, fromId); - return next; - }; - - const handleReorder = (visibleOrder: string[]) => { - setReorderList(visibleOrder); - }; + }, [draggingId, isReorderMode]); - const handleDragStart = (id: string) => (event: React.DragEvent) => { - setDraggingId(id); + const handleDragStart = (event: DragStartEvent) => { + if (!isReorderMode) return; + setDraggingId(event.active.id as string); setDragOverId(null); - event.dataTransfer.effectAllowed = "move"; }; - const handleDragOver = (overId: string) => (event: React.DragEvent) => { - event.preventDefault(); - if (!draggingId || draggingId === overId) return; + const handleDragOver = (event: DragOverEvent) => { + if (!isReorderMode) return; + const overId = event.over?.id as string | undefined; + if (!overId || overId === draggingId) { + setDragOverId(null); + return; + } setDragOverId(overId); }; - const handleDragLeave = () => { - setDragOverId(null); - }; - - const handleDrop = (overId: string) => (event: React.DragEvent) => { - event.preventDefault(); - if (draggingId && overId && draggingId !== overId) { - setReorderList((prev) => moveIdInList(prev, draggingId, overId)); + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!isReorderMode) { + setDraggingId(null); + setDragOverId(null); + return; } + setDraggingId(null); + + if (!over) { + setDragOverId(null); + return; + } + + const activeId = active.id as string; + const overId = over.id as string; + setDragOverId(null); + + if (activeId !== overId) { + setReorderList((prev) => { + const oldIndex = prev.indexOf(activeId); + const newIndex = prev.indexOf(overId); + if (oldIndex === -1 || newIndex === -1) return prev; + return arrayMove(prev, oldIndex, newIndex); + }); + } }; - const handleDragEnd = () => { + const handleDragCancel = () => { setDraggingId(null); setDragOverId(null); }; @@ -227,6 +255,119 @@ export const ScenesScreen: React.FC = () => { const handleSuccessClose = () => setSuccessOpen(false); + const SortableSceneCard: React.FC<{ scene: SceneSummary; isExecuting: boolean; sceneError?: string | null }> = ({ + scene, + isExecuting, + sceneError, + }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({ + id: scene.sceneId, + disabled: !isReorderMode, + }); + + return ( + + {isReorderMode && ( + + + + )} + + + ); + }; + if (!apiToken) { return ( @@ -293,129 +434,129 @@ export const ScenesScreen: React.FC = () => { {!error && isTokenValid && scenes.length === 0 && ( {t("No scenes found. Create a manual scene in the SwitchBot app.")} )} - - - - {(isReorderMode - ? reorderList - .map((sceneId) => sceneMap.get(sceneId)) - .filter((scene): scene is SceneSummary => !!scene) - : orderedScenes - ).map((scene) => { - const isExecuting = !!executingById[scene.sceneId]; - const sceneError = executionErrorById[scene.sceneId]; - return ( - - - {isReorderMode && ( - + + + + {isReorderMode ? ( + + {reorderList + .map((sceneId) => sceneMap.get(sceneId)) + .filter((scene): scene is SceneSummary => !!scene) + .map((scene) => { + const isExecuting = !!executingById[scene.sceneId]; + const sceneError = executionErrorById[scene.sceneId]; + return ( + + + + ); + })} + + ) : ( + orderedScenes.map((scene) => { + const isExecuting = !!executingById[scene.sceneId]; + const sceneError = executionErrorById[scene.sceneId]; + return ( + + - - - )} - - - - ); - })} - - - + {isExecuting ? ( + <> + + + {t("Executing...")} + + + ) : ( + + {scene.sceneName || t("Unnamed Scene")} + + )} + {sceneError && ( + + Error + + )} + + + + ); + }) + )} + + + + )}