Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
226 changes: 129 additions & 97 deletions src/renderer/src/components/DeviceListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -69,6 +85,14 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) =>
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);

const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
})
);

const orderedDevices = useMemo(() => {
const idToDevice = new Map<string, AnyDevice>();
devices.forEach((device) => {
Expand Down Expand Up @@ -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);
};

Expand All @@ -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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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;
Comment on lines +218 to +224

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For improved readability and to align with dnd-kit conventions, you can use the arrayMove utility from @dnd-kit/sortable to handle the reordering logic. This makes the code more concise and the intent clearer.

You'll need to add arrayMove to your imports from @dnd-kit/sortable.

Suggested change
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;
const oldIndex = prev.indexOf(active.id as string);
const newIndex = prev.indexOf(over.id as string);
return oldIndex > -1 && newIndex > -1 ? arrayMove(prev, oldIndex, newIndex) : prev;

});
}

setDraggingId(null);
setDragOverId(null);
};

const handleDragEnd = () => {
setDraggingId(null);
setDragOverId(null);
};

const startReorder = () => {
setReorderList(filteredDevices.map((device) => device.deviceId));
setIsReorderMode(true);
Expand All @@ -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 (
<Container sx={{ mt: 4 }}>
Expand Down Expand Up @@ -322,64 +328,90 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) =>
</Box>
)}

<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
<Box sx={gridSx}>
<AnimatePresence>
{(isReorderMode
? reorderList
.map((deviceId) => filteredDeviceMap.get(deviceId))
.filter((device): device is AnyDevice => !!device)
: filteredDevices
).map((device) => (
<motion.div variants={itemVariants} layout key={device.deviceId}>
<Box
sx={{
position: "relative",
opacity: draggingId === device.deviceId ? 0.5 : 1,
transition: "opacity 0.2s ease",
outline: dragOverId === device.deviceId ? "2px solid" : "none",
outlineColor: "primary.main",
outlineOffset: "2px",
borderRadius: 2,
}}
draggable={isReorderMode}
onDragStart={isReorderMode ? handleDragStart(device.deviceId) : undefined}
onDragOver={isReorderMode ? handleDragOver(device.deviceId) : undefined}
onDragLeave={isReorderMode ? handleDragLeave : undefined}
onDrop={isReorderMode ? handleDrop(device.deviceId) : undefined}
onDragEnd={isReorderMode ? handleDragEnd : undefined}
>
{isReorderMode && (
<IconButton
size="small"
sx={{
position: "absolute",
top: 8,
right: 8,
bgcolor: "background.paper",
boxShadow: 1,
pointerEvents: "none",
}}
aria-label={t("Reorder")}
>
<DragIndicatorIcon fontSize="small" />
</IconButton>
)}
<DeviceCard
device={device}
status={statusMap[device.deviceId]}
onSelect={onDeviceSelect}
/>
</Box>
</motion.div>
))}
</AnimatePresence>
</Box>
<motion.div variants={containerVariants} initial="hidden" animate="visible">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={isReorderMode ? handleDragStart : undefined}
onDragOver={isReorderMode ? handleDragOver : undefined}
onDragEnd={isReorderMode ? handleDragEnd : undefined}
>
<SortableContext
items={sortableItems.map((device) => device.deviceId)}
strategy={rectSortingStrategy}
>
<Box sx={gridSx}>
<AnimatePresence>
{sortableItems.map((device) => (
<motion.div variants={itemVariants} layout key={device.deviceId}>
<SortableDeviceCard
device={device}
status={statusMap[device.deviceId]}
onSelect={onDeviceSelect}
isReorderMode={isReorderMode}
isDragging={draggingId === device.deviceId}
isOver={dragOverId === device.deviceId}
translationDisabled={!isReorderMode}
/>
</motion.div>
))}
</AnimatePresence>
</Box>
</SortableContext>
</DndContext>
</motion.div>
</Container>
);
};

const SortableDeviceCard: React.FC<{
device: AnyDevice;
status?: Record<string, any>;
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 (
<Box ref={setNodeRef} style={style} {...(isReorderMode ? { ...attributes, ...listeners } : {})}>
<Box
sx={{
position: "relative",
opacity: isDragging || isSorting ? 0.5 : 1,
transition: "opacity 0.2s ease",
outline: isOver ? "2px solid" : "none",
outlineColor: "primary.main",
outlineOffset: "2px",
borderRadius: 2,
cursor: isReorderMode ? "grab" : "default",
}}
>
{isReorderMode && (
<IconButton
size="small"
sx={{
position: "absolute",
top: 8,
right: 8,
bgcolor: "background.paper",
boxShadow: 1,
pointerEvents: "none",
}}
aria-label="並び替え"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The aria-label for the drag handle is hardcoded in Japanese. To support internationalization, this should be a translated string passed down as a prop from the parent DeviceListScreen component, which has access to the useTranslation hook.

Example in DeviceListScreen:

<SortableDeviceCard
  {...otherProps}
  reorderAriaLabel={t("Reorder")}
/>

Then use this prop in SortableDeviceCard.

>
<DragIndicatorIcon fontSize="small" />
</IconButton>
)}
<DeviceCard device={device} status={status} onSelect={onSelect} />
</Box>
</Box>
);
};
Loading