1. 필수 패키지 설치
yarn add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
2. DragHandle 스타일 추가
// src/pages/CreateProject/CreateProject.style.ts
export const DragHandle = styled.div`
cursor: grab;
display: inline-flex;
align-items: center;
margin-bottom: 0.5rem;
padding: 0.25rem;
color: #999;
&:hover {
color: #333;
}
/* 💡 모바일 대응 핵심은 센서 설정이므로 아래는 생략 가능하지만, 원하면 추가 가능 */
/* touch-action: none; */
/* user-select: none; */
`;
3. 센서 설정 및 Context 통합
Import
import {
DndContext,
closestCenter,
useSensor,
useSensors,
MouseSensor,
TouchSensor,
DragEndEvent
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
Sensor 설정 (모바일 대응)
const sensors = useSensors(
useSensor(MouseSensor), // 마우스
useSensor(TouchSensor, {
activationConstraint: {
delay: 100, // 100ms 이상 터치해야 드래그 시작
tolerance: 5 // 5px 이내의 움직임은 무시 (터치 오작동 방지)
}
})
);
handleDragEnd 함수 작성
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = fields.findIndex(f => f.id === active.id);
const newIndex = fields.findIndex(f => f.id === over.id);
setFields(arrayMove(fields, oldIndex, newIndex));
};
4. 필드 드래그 UI 구현
DndContext & SortableContext 래핑
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={fields.map(f => f.id)}
strategy={verticalListSortingStrategy}
>
{fields.map(field => (
<SortableField
key={field.id}
field={field}
logType={logType}
onFieldChange={handleFieldChange}
onRemoveField={handleRemoveField}
/>
))}
</SortableContext>
</DndContext>
SortableField 컴포넌트 작성
function SortableField({
field,
logType,
onFieldChange,
onRemoveField
}: {
field: { id: string; name: string; path: string };
logType: "json" | "plainText" | "csv" | "xml";
onFieldChange: (id: string, key: "name" | "path", value: string) => void;
onRemoveField: (id: string) => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id: field.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
boxShadow: isDragging ? "0 2px 8px rgba(0,0,0,0.2)" : undefined,
backgroundColor: isDragging ? "rgba(67,97,238,0.05)" : undefined
};
return (
<FieldContainer ref={setNodeRef} style={style}>
<DragHandle {...attributes} {...listeners}>☰</DragHandle>
<FieldRow>
<FieldLabel>필드 이름</FieldLabel>
<FieldInputContainer>
<input
type="text"
value={field.name}
onChange={e => onFieldChange(field.id, "name", e.target.value)}
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e9ecef",
borderRadius: "4px"
}}
placeholder="timestamp, logLevel, message 등"
/>
</FieldInputContainer>
<RemoveButton type="button" onClick={() => onRemoveField(field.id)}>
삭제
</RemoveButton>
</FieldRow>
<FieldRow>
<FieldLabel>{logType === "json" ? "JSON 경로" : "정규식 패턴"}</FieldLabel>
<FieldInputContainer>
<input
type="text"
value={field.path}
onChange={e => onFieldChange(field.id, "path", e.target.value)}
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e9ecef",
borderRadius: "4px"
}}
placeholder={logType === "json" ? "data.timestamp" : "^\\d{4}-\\d{2}-\\d{2}"}
/>
</FieldInputContainer>
</FieldRow>
</FieldContainer>
);
}