From fc4e6b95be04a0ee69b2380e6009b05b2ab11e6f Mon Sep 17 00:00:00 2001 From: suu <46421931+0suu@users.noreply.github.com> Date: Thu, 27 Nov 2025 02:40:06 +0900 Subject: [PATCH] Enable scrollable drag sorting with dnd-kit --- package.json | 3 + .../src/components/DeviceListScreen.tsx | 226 ++++++----- src/renderer/src/components/ScenesScreen.tsx | 365 ++++++++++-------- 3 files changed, 344 insertions(+), 250 deletions(-) diff --git a/package.json b/package.json index a453ee0..c09d167 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": "^9.0.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..2a8463e 100644 --- a/src/renderer/src/components/DeviceListScreen.tsx +++ b/src/renderer/src/components/DeviceListScreen.tsx @@ -29,6 +29,22 @@ import { useTranslation } from "../useTranslation"; import SwapVertIcon from "@mui/icons-material/SwapVert"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import { AnyDevice } from "../../../api/types"; +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, + DragStartEvent, + DragEndEvent, + DragOverEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + rectSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; // Animation variants const containerVariants = { @@ -69,6 +85,14 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) => const [draggingId, setDraggingId] = useState(null); const [dragOverId, setDragOverId] = useState(null); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 6, + }, + }) + ); + const orderedDevices = useMemo(() => { const idToDevice = new Map(); devices.forEach((device) => { @@ -161,12 +185,11 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) => }); }, [filteredDevices, isReorderMode]); - // Enable wheel scrolling during drag + // ドラッグ中でもホイールスクロールできるようにする useEffect(() => { if (!draggingId) return; const handleWheel = (e: WheelEvent) => { - // Allow default wheel behavior (scrolling) during drag window.scrollBy(0, e.deltaY); }; @@ -177,56 +200,35 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) => }; }, [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; - }; - - const handleDragStart = (id: string) => (event: React.DragEvent) => { - setDraggingId(id); + const handleDragStart = (event: DragStartEvent) => { + 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) => { + const overId = event.over?.id as string | undefined; + if (!overId) return; setDragOverId(overId); }; - const handleDragLeave = () => { - setDragOverId(null); - }; - - const handleDrop = (overId: string) => (event: React.DragEvent) => { - event.preventDefault(); - - if (draggingId && overId && draggingId !== overId) { - // Update the list only once on drop - setReorderList((prev) => moveIdInList(prev, draggingId, overId)); + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + setReorderList((prev) => { + const oldIndex = prev.indexOf(active.id as string); + const newIndex = prev.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1) return prev; + const next = [...prev]; + next.splice(oldIndex, 1); + next.splice(newIndex, 0, active.id as string); + return next; + }); } setDraggingId(null); setDragOverId(null); }; - const handleDragEnd = () => { - setDraggingId(null); - setDragOverId(null); - }; - const startReorder = () => { setReorderList(filteredDevices.map((device) => device.deviceId)); setIsReorderMode(true); @@ -250,6 +252,10 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) => } }; + const sortableItems = isReorderMode + ? reorderList.map((deviceId) => filteredDeviceMap.get(deviceId)).filter((device): device is AnyDevice => !!device) + : filteredDevices; + if (!apiTokenSet) { return ( @@ -322,64 +328,90 @@ 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 && ( - - - - )} - - - - ))} - - + + + device.deviceId)} + strategy={rectSortingStrategy} + > + + + {sortableItems.map((device) => ( + + + + ))} + + + + ); }; + +const SortableDeviceCard: React.FC<{ + device: AnyDevice; + status?: Record; + onSelect: (deviceId: string) => void; + isReorderMode: boolean; + isDragging: boolean; + isOver: boolean; + translationDisabled: boolean; +}> = ({ device, status, onSelect, isReorderMode, isDragging, isOver, translationDisabled }) => { + const { attributes, listeners, setNodeRef, transform, transition, isSorting } = useSortable({ id: device.deviceId }); + + const style = { + transform: translationDisabled ? undefined : CSS.Transform.toString(transform), + transition: translationDisabled ? undefined : transition, + }; + + return ( + + + {isReorderMode && ( + + + + )} + + + + ); +}; diff --git a/src/renderer/src/components/ScenesScreen.tsx b/src/renderer/src/components/ScenesScreen.tsx index 033a1dc..a8e21d0 100644 --- a/src/renderer/src/components/ScenesScreen.tsx +++ b/src/renderer/src/components/ScenesScreen.tsx @@ -11,6 +11,18 @@ 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, + PointerSensor, + closestCenter, + useSensor, + useSensors, + DragStartEvent, + DragOverEvent, + DragEndEvent, +} from "@dnd-kit/core"; +import { SortableContext, rectSortingStrategy, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { AppDispatch, RootState } from "../store/store"; import { useTranslation } from "../useTranslation"; @@ -68,6 +80,13 @@ 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: 6, + }, + }) + ); const orderedScenes: SceneSummary[] = useMemo(() => { const idToScene = new Map(); @@ -133,6 +152,7 @@ export const ScenesScreen: React.FC = () => { }); }, [orderedScenes, isReorderMode]); + // ドラッグ中でもホイールスクロールできるようにする useEffect(() => { if (!draggingId) return; @@ -147,47 +167,31 @@ export const ScenesScreen: React.FC = () => { }; }, [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); - }; - - const handleDragStart = (id: string) => (event: React.DragEvent) => { - setDraggingId(id); + const handleDragStart = (event: DragStartEvent) => { + 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) => { + const overId = event.over?.id as string | undefined; + if (!overId) 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 (over && active.id !== over.id) { + setReorderList((prev) => { + const oldIndex = prev.indexOf(active.id as string); + const newIndex = prev.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1) return prev; + const next = [...prev]; + next.splice(oldIndex, 1); + next.splice(newIndex, 0, active.id as string); + return next; + }); } - setDraggingId(null); - setDragOverId(null); - }; - const handleDragEnd = () => { setDraggingId(null); setDragOverId(null); }; @@ -215,6 +219,10 @@ export const ScenesScreen: React.FC = () => { } }; + const sortableItems = isReorderMode + ? reorderList.map((sceneId) => sceneMap.get(sceneId)).filter((scene): scene is SceneSummary => !!scene) + : orderedScenes; + const handleRefresh = () => { if (isTokenValid && apiToken) { dispatch(fetchScenes()); @@ -294,127 +302,48 @@ export const ScenesScreen: React.FC = () => { {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 && ( - - - - )} - - - - ); - })} - - + scene.sceneId)} strategy={rectSortingStrategy}> + + + {sortableItems.map((scene) => { + const isExecuting = !!executingById[scene.sceneId]; + const sceneError = executionErrorById[scene.sceneId]; + return ( + + + + ); + })} + + + + )} @@ -432,3 +361,133 @@ export const ScenesScreen: React.FC = () => { ); }; + +const SortableSceneButton: React.FC<{ + scene: SceneSummary; + isExecuting: boolean; + sceneError?: string; + isTokenValid: boolean; + isReorderMode: boolean; + isDragging: boolean; + isOver: boolean; + translationDisabled: boolean; + onExecute: (sceneId: string) => void; + label: string; + unnamedLabel: string; +}> = ({ + scene, + isExecuting, + sceneError, + isTokenValid, + isReorderMode, + isDragging, + isOver, + translationDisabled, + onExecute, + label, + unnamedLabel, +}) => { + const { attributes, listeners, setNodeRef, transform, transition, isSorting } = useSortable({ id: scene.sceneId }); + + const style = { + transform: translationDisabled ? undefined : CSS.Transform.toString(transform), + transition: translationDisabled ? undefined : transition, + }; + + return ( + + + {isReorderMode && ( + + + + )} + + + + ); +};