diff --git a/package.json b/package.json index a453ee0..9ddb84f 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.3.1", + "@dnd-kit/sortable": "^8.0.1", + "@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..2d4cef3 100644 --- a/src/renderer/src/components/DeviceListScreen.tsx +++ b/src/renderer/src/components/DeviceListScreen.tsx @@ -9,6 +9,19 @@ 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, + MouseSensor, + TouchSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { SortableContext, rectSortingStrategy, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { AppDispatch } from "../store/store"; import { @@ -69,6 +82,11 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) => const [draggingId, setDraggingId] = useState(null); const [dragOverId, setDragOverId] = useState(null); + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }) + ); + const orderedDevices = useMemo(() => { const idToDevice = new Map(); devices.forEach((device) => { @@ -105,6 +123,18 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) => return map; }, [filteredDevices]); + const displayedDevices = useMemo( + () => + isReorderMode + ? reorderList + .map((deviceId) => filteredDeviceMap.get(deviceId)) + .filter((device): device is AnyDevice => !!device) + : filteredDevices, + [filteredDeviceMap, filteredDevices, isReorderMode, reorderList] + ); + + const sortableIds = useMemo(() => displayedDevices.map((device) => device.deviceId), [displayedDevices]); + const gridSx = useMemo( () => ({ display: "grid", @@ -163,23 +193,18 @@ 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); - }; + }, [draggingId, isReorderMode]); const moveIdInList = (list: string[], fromId: string, toId: string) => { if (fromId === toId) return list; @@ -192,37 +217,36 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) => return next; }; - const handleDragStart = (id: string) => (event: React.DragEvent) => { + const handleDragStart = (event: DragStartEvent) => { + if (!isReorderMode) return; + const id = event.active.id as string; setDraggingId(id); 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; + const activeId = event.active.id as string; + if (!overId || activeId === 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) => { + if (!isReorderMode) { + setDraggingId(null); + setDragOverId(null); + return; + } + const { active, over } = event; + if (over && active.id !== over.id) { + setReorderList((prev) => moveIdInList(prev, active.id as string, over.id as string)); } - setDraggingId(null); setDragOverId(null); }; - const handleDragEnd = () => { + const handleDragCancel = () => { setDraggingId(null); setDragOverId(null); }; @@ -250,6 +274,60 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) => } }; + const SortableDeviceCard: React.FC<{ device: AnyDevice }> = ({ device }) => { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ + id: device.deviceId, + disabled: !isReorderMode, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( + + + {isReorderMode && ( + + + + )} + + + + ); + }; + if (!apiTokenSet) { return ( @@ -327,58 +405,25 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) => initial="hidden" animate="visible" > - - - {(isReorderMode - ? reorderList - .map((deviceId) => filteredDeviceMap.get(deviceId)) - .filter((device): device is AnyDevice => !!device) - : filteredDevices - ).map((device) => ( - - - {isReorderMode && ( - - - - )} - - - - ))} - - + + + + + {displayedDevices.map((device) => ( + + ))} + + + + ); diff --git a/src/renderer/src/components/ScenesScreen.tsx b/src/renderer/src/components/ScenesScreen.tsx index 033a1dc..7902ccb 100644 --- a/src/renderer/src/components/ScenesScreen.tsx +++ b/src/renderer/src/components/ScenesScreen.tsx @@ -11,6 +11,19 @@ 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, + MouseSensor, + TouchSensor, + closestCenter, + useSensor, + useSensors, +} 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 +81,10 @@ export const ScenesScreen: React.FC = () => { const [reorderList, setReorderList] = useState([]); const [draggingId, setDraggingId] = useState(null); const [dragOverId, setDragOverId] = useState(null); + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }) + ); const orderedScenes: SceneSummary[] = useMemo(() => { const idToScene = new Map(); @@ -98,6 +115,18 @@ export const ScenesScreen: React.FC = () => { return map; }, [orderedScenes]); + const displayedScenes = useMemo( + () => + isReorderMode + ? reorderList + .map((sceneId) => sceneMap.get(sceneId)) + .filter((scene): scene is SceneSummary => !!scene) + : orderedScenes, + [isReorderMode, orderedScenes, reorderList, sceneMap] + ); + + const sortableIds = useMemo(() => displayedScenes.map((scene) => scene.sceneId), [displayedScenes]); + useEffect(() => { if (isTokenValid && apiToken) { dispatch(fetchScenes()); @@ -134,7 +163,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,7 +174,7 @@ export const ScenesScreen: React.FC = () => { return () => { window.removeEventListener("wheel", handleWheel); }; - }, [draggingId]); + }, [draggingId, isReorderMode]); const moveIdInList = (list: string[], fromId: string, toId: string) => { if (fromId === toId) return list; @@ -158,36 +187,35 @@ export const ScenesScreen: React.FC = () => { return next; }; - const handleReorder = (visibleOrder: string[]) => { - setReorderList(visibleOrder); - }; - - 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; + const activeId = event.active.id as string; + if (!overId || overId === activeId) 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) => { + if (!isReorderMode) { + setDraggingId(null); + setDragOverId(null); + return; + } + const { active, over } = event; + if (over && active.id !== over.id) { + setReorderList((prev) => moveIdInList(prev, active.id as string, over.id as string)); } setDraggingId(null); setDragOverId(null); }; - const handleDragEnd = () => { + const handleDragCancel = () => { setDraggingId(null); setDragOverId(null); }; @@ -215,6 +243,119 @@ export const ScenesScreen: React.FC = () => { } }; + const SortableSceneCard: React.FC<{ scene: SceneSummary; isExecuting: boolean; sceneError?: string }> + = ({ scene, isExecuting, sceneError }) => { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ + id: scene.sceneId, + disabled: !isReorderMode, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( + + + {isReorderMode && ( + + + + )} + + + + ); + }; + const handleRefresh = () => { if (isTokenValid && apiToken) { dispatch(fetchScenes()); @@ -294,127 +435,37 @@ 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 && ( - - - - )} - - - - ); - })} - - + + + + {displayedScenes.map((scene) => ( + + ))} + + + + )}