λ°λͺ¨ μ¬μ΄νΈ: https://kanban.romantech.net
- Core: Next.js 15, TypeScript
- State Management: Zustand
- UI Library: Shadcn/ui
- Styling: Tailwind CSS
- Form Handling: React Hook Form, Zod
- Others: DnD Kit, Motion, LangChain, Unkey
- μΉΈλ° μμ κ΄λ¦¬: 보λ/컬λΌ/μμ CRUD
- λλκ·Έμ€λλ‘ μ§μ: 컬λΌ, μμ μ΄λ
- AI κΈ°λ° νμ μμ μλ μμ±
- νμ μμ μ§ν νν© νμ
- κΈ°λ³Έ μ»¬λΌ ν νλ¦Ώ μ 곡
- 보λ κ²μ
- μΉΈλ° λ°μ΄ν° λͺ¨λΈ
- λλκ·Έμ€λλ‘
- μ»¬λΌ μμ λ³κ²½
- νμ€ν¬ μμ λ³κ²½ / μ»¬λΌ κ° μ΄λ
- μΌμ μ‘°μ
- μ΄μ ν΄κ²°
ID κΈ°λ° μ°Έμ‘° ꡬ쑰λ₯Ό μ¬μ©νλ©΄ μν°ν° κ° κ΄κ³λ₯Ό μ μ§νλ©΄μ λΉ λ₯Έ μ‘°ν μ±λ₯μ 보μ₯ν μ μλ€. κ° μν°ν°λ κ³ μ ν id
λ₯Ό κ°μ§λ©°, μΈλ ν€(boardId
, columnId
)λ₯Ό μ¬μ©νμ¬ μ°Έμ‘° 무결μ±μ μ μ§νλ€. μΉΈλ° λ³΄λλ μ¬λ¬ κ°μ 컬λΌμ ν¬ν¨νκ³ , κ° μ»¬λΌμ λ€μ μ¬λ¬κ°μ νμ€ν¬λ‘ ꡬμ±λλ κ³μΈ΅μ μΈ κ΅¬μ‘°λ₯Ό κ°μ§λ€.
erDiagram
BOARD ||--o{ COLUMN : contains
COLUMN ||--o{ TASK : contains
BOARD {
string id PK "보λμ κ³ μ μλ³μ"
string title "보λμ μ λͺ©"
string createdAt "보λ μμ± μκ°"
string updatedAt "보λ μ
λ°μ΄νΈ μκ°"
array columnIds FK "보λμ μν μ»¬λΌ ID 리μ€νΈ"
}
COLUMN {
string id PK "컬λΌμ κ³ μ μλ³μ"
string boardId FK "컬λΌμ΄ μν 보λ ID"
string title "μ»¬λΌ μ λͺ©"
string createdAt "μ»¬λΌ μμ± μκ°"
string updatedAt "μ»¬λΌ μ
λ°μ΄νΈ μκ°"
array taskIds FK "컬λΌμ μν Task ID 리μ€νΈ"
}
TASK {
string id PK "νμ€ν¬μ κ³ μ μλ³μ"
string columnId FK "νμ€ν¬κ° μν μ»¬λΌ ID"
string title "νμ€ν¬ μ λͺ©"
string description "νμ€ν¬ μ€λͺ
(μ ν)"
string createdAt "νμ€ν¬ μμ± μκ°"
string updatedAt "νμ€ν¬ μ
λ°μ΄νΈ μκ°"
}
Record<TaskId, TaskDef>
ννλ‘ μ μ₯νλ©΄ O(1) μκ° λ³΅μ‘λλ‘ λ°μ΄ν° μ‘°ν κ°λ₯boardId
,columnId
κ°μ FK(Foreign Key, μΈλ ν€)λ₯Ό μ¬μ©νμ¬ μ°Έμ‘° λ¬΄κ²°μ± λ³΄μ₯- νμμ λ°λΌ
assigneeId
,priority
κ°μ νλλ₯Ό μ½κ² μΆκ°ν μ μμ
리μ€νΈλ₯Ό SortableContext
λ‘ κ°μΈκ³ , 리μ€νΈμ κ° μμ΄ν
μ useSortable
ν
λ°νκ°μ μ μ©νλ©΄ λλκ·Έμ€λλ‘μΌλ‘ μμ΄ν
μμλ₯Ό λ³κ²½ ν μ μλ€. μ°Έκ³ λ‘ useSortable
ν
μ useDraggable
, useDroppable
ν
μ κ²°ν©νμ¬ λ§λ ν리μ
μ΄λ€.
- SortableContext: 리μ€νΈ μμ κ΄λ¦¬
- useSortable: λλκ·Έμ€λλ‘ κΈ°λ₯ λΆμ¬, μμ΄ν μμΉ λ³νμ νμν μνμ μ΄λ²€νΈ νΈλ€λ¬ μ 곡.
Taskλ λΆλͺ¨ 컨ν
μ΄λ(μν΄μλ 컬λΌ)λ₯Ό λ²μ΄λ λ€λ₯Έ 컬λΌμΌλ‘ μ΄λν μ μμ΄μΌ νλ―λ‘, λλκ·Έ μμ΄ν
μ DragOverlay
λ‘ κ°μΈμΌ νλ€. DragOverlay
λ κΈ°μ‘΄ λ¬Έμ νλ¦μμ λΆλ¦¬λμ΄ λ·°ν¬νΈλ₯Ό κΈ°μ€μΌλ‘ λλκ·Έ κ°λ₯ν μ€λ²λ μ΄λ₯Ό λ λλ§νλ€.
// ...
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext } from '@dnd-kit/sortable';
const Board = () => {
// ...
// onDragStart, onDragEnd λ± νΈλ€λ¬ λ‘μ§μ μ μν 컀μ€ν
ν
const { handlers, dragColumnId, dragTaskId, /* ... */ } = useKanbanDnd();
return (
<div className="...">
{/* 리μ€νΈλ₯Ό DndContext, SortableContextλ‘ κ°μΈμ€λ€ */}
<DndContext {...handlers} id={...} sensors={...} modifiers={...}>
<SortableContext items={board.columnIds} id={board.id}>
{board.columnIds.map((columnId) => (
<Column key={columnId} columnId={columnId} />
))}
</SortableContext>
<DragOverlay>
{/* λλκ·Έ μ€μΈ μμ΄ν
*/}
{dragColumnId && <Column columnId={toColumnId(dragColumnId)} />}
{dragTaskId && <Task taskId={toTaskId(dragTaskId)} />}
</DragOverlay>
</DndContext>
</div>
);
};
λλκ·Έλ₯Ό μμν λ νμ¬ μμκ° μ»¬λΌμΈμ§ νμ€ν¬μΈμ§ νλ³ν ν, DragOverlay
μμ μ‘°κ±΄λΆ λ λλ§ν΄μΌ νλ€. μ΄λ₯Ό μν΄ onDragStart
νΈλ€λ¬μμ κ° νμ
(task, column)μ ν΄λΉνλ id
λ₯Ό λ³λ μνλ‘ κ΄λ¦¬νλ€.
// use-drag-state.ts
const [dragColumnId, setDragColumnId] = useState<T>();
const [dragTaskId, setDragTaskId] = useState<T>();
const setDragState = useCallback((type: 'task' | 'column', value: T) => {
const setStateMap = { task: setDragTaskId, column: setDragColumnId };
setStateMap[type](value);
}, []);
// use-kanban-dnd.ts
const onDragStart = ({ active }: DragStartEvent) => {
const dragType = getDragTypes(active).isActiveTask ? 'task' : 'column';
setDragState(dragType, active.id);
};
리μ€νΈ μμ΄ν
μμ useSortable
ν
μ΄ λ°ννλ κ°λ€μ λλκ·Έ λμ μμμ μ μ©νλ€. useSortable
ν
μ΄ λ°λ id
, data
λ± μΈμλ μ΄λ²€νΈ νΈλ€λ¬λ‘ μ λ¬λλ€(active, over μμ±μ μΆκ°λ¨).
μ 체 μμκ° μλ νΉμ λΆλΆμ ν΄λ¦νμλλ§ λλκ·Έλλλ‘ νκ³ μΆλ€λ©΄ listeners
νΈλ€λ¬λ₯Ό λ€λ₯Έ μμμ ν λΉνλ©΄ λλ€. κΈ°λ³Έμ μΌλ‘ λλ‘ μ§μ μ λλκ·Έ λμ μμκ° κ·Έλλ‘ λ λλ§λλ€. ν
λλ¦¬λ§ λ³΄μ΄κ±°λ νΉμ μ€νμΌμ μ μ©νκ³ μΆλ€λ©΄ isDragging
μνλ₯Ό νμ©ν΄μ μ‘°κ±΄λΆ λ λλ§μΌλ‘ μ²λ¦¬νλ©΄ λλ€.
// ...
import { CSS } from '@dnd-kit/utilities';
import { useSortable } from '@dnd-kit/sortable';
const Column = ({ boardId, children }) => {
const {
attributes, // μ κ·Όμ±μ μν ARIA μμ± (λλκ·Έ μμμ μ μ©)
listeners, // λλκ·Έ μμμ κ°μ§νλ μ΄λ²€νΈ νΈλ€λ¬
setNodeRef, // μμλ₯Ό λλκ·Έ λμμΌλ‘ μ€μ νλ ref ν¨μ
transform, // λλκ·Έ μμμ μμΉ λ³νλ₯Ό λνλ΄λ κ° (x, y, scaleX, scaleY)
transition, // λλκ·Έ μμμ μ λλ©μ΄μ
ν¨κ³Όλ₯Ό μ μ©νλ κ°
isDragging, // νμ¬ μμκ° λλκ·Έ μ€μΈμ§ μ¬λΆ boolean
} = useSortable({
id: boardId, // μμ΄ν
μ κ³ μ ID
data: { type: 'column' }, // μ λ¬ν λ°μ΄ν°
});
const style: CSSProperties = {
transform: CSS.Transform.toString(transform), // μμΉ μ΄λ μ€νμΌ μ μ©
transition, // μ λλ©μ΄μ
ν¨κ³Ό μ μ©
};
if (isDragging) {
// λλκ·Έ μ€μΌ λ λλ‘ μμμ νμν Placeholder
return <DropPlaceholder variant={type} style={style} ref={setNodeRef} />;
}
return (
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
{children}
</div>
);
};
π‘ λ΄λΆμμ useSortable
ν
μ νΈμΆνλ Draggable κ³΅ν΅ μ»΄ν¬λνΈλ₯Ό λ§λ€μ΄μ μ¬μ©νλ©΄ μ½λκ° κΉλν΄μ§λ€.
컬λΌμ 컨ν
μ΄λλ νμ Boardμ΄λ―λ‘ μμ΄ν
μ λλ‘νμ λ νΈμΆλλ onDragEnd
νΈλ€λ¬μμ μ²λ¦¬ν μ μλ€. νΈλ€λ¬μ μΈμλ‘ μ λ¬λλ active
λ λλκ·Έ μ€μΈ μμ΄ν
μ, over
λ λλ‘ μμΉμ 컨ν
μ΄λλ₯Ό μ°Έμ‘°νλ€.
const onDragEnd = ({ active, over }: DragEndEvent) => {
// λλκ·Έ μ€μΈ μμ΄ν
μ΄ DragOverlayμμ λ λλ§ λμ§ μλλ‘ dragColumnId, dragTaskId μ΄κΈ°ν
// νλ¨μ early return μμΌλ―λ‘ μ΅μλ¨μμ μ΄κΈ°ν
resetDragState();
if (!over) return; // λλ‘ μμ λ²μ΄λ¬μ λ
if (active.id === over?.id) return; // κ°μ μμΉλ μ€ν΅
if (!getDragTypes(active).isActiveColumn) return; // Column λλκ·Έκ° μλλ©΄ μ€ν΅
const activeSort = active.data.current?.sortable as ColumnSortable;
const overSort = over?.data.current?.sortable as ColumnSortable;
// arrayMoveλ dnd-kit μ체μ μΌλ‘ μ 곡νλ ν¬νΌ ν¨μ
const newColumnIds = arrayMove(activeSort.items, activeSort.index, overSort.index);
moveColumn(activeSort.containerId, newColumnIds);
};
Sortable ν리μ
μ μ¬μ©νλ©΄ active
, over
κ°μ²΄μ sortable
μ΄λΌλ μ μ©ν μμ±μ΄ μΆκ°λλ€. μ΄ μμ±μ νμ©νλ©΄ λλκ·Έ μ€μΈ μμ΄ν
μ΄ μν 컨ν
μ΄λ ID, μΈλ±μ€, 컨ν
μ΄λ λ΄μ μ 체 μμ΄ν
λͺ©λ‘μ λ°λ‘ μ°Έμ‘°ν μ μμ΄μ μ½λλ₯Ό λ κ°κ²°νκ² μμ±ν μ μλ€.
// active
const active = {
id: 'Column-rYZJ5DsW8WyWUrIZ-tN9p', // νμ¬ μμ΄ν
id (useSortable μΈμλ‘ λκ²Όλ id)
data: {
current: {
sortable: {
// νμ¬ μμ΄ν
μ΄ μν 컨ν
μ΄λ ID (SortableContextμ λκ²Όλ id)
containerId: 'Board-q0tzC2fGuBReTjYuzdUHL',
// 컨ν
μ΄λ λ΄μμ νμ¬ μμ΄ν
μ μΈλ±μ€
index: 0,
// 컨ν
μ΄λμ μ 체 μμ΄ν
λͺ©λ‘ (SortableContextμ λκ²Όλ items)
items: [
'Column-rYZJ5DsW8WyWUrIZ-tN9p', // νμ¬ μμ΄ν
'Column-uh6jGsYnCfu3peUX5ZF-O',
],
},
// ...
type: 'column', // νμ¬ μμ΄ν
type (useSortable μΈμλ‘ λκ²Όλ type)
},
},
// ...
};
π‘ dnd-kit λΌμ΄λΈλ¬λ¦¬ μ체μ μΌλ‘ arraySwap
, arrayMove
μ νΈλ¦¬ν° ν¨μλ₯Ό μ 곡νλ€. arraySwap
μ λ°°μ΄ λ΄ λ μμμ μμΉλ₯Ό κ΅ννκ³ , arrayMove
λ μ§μ ν μΈλ±μ€λ‘ μμλ₯Ό μ΄λμν¨ ν λλ¨Έμ§ μμλ₯Ό λ°μ΄λΈλ€.
import { arraySwap, arrayMove } from '@dnd-kit/sortable';
const items = ['A', 'B', 'C', 'D'];
const swapped = arraySwap(items.slice(), 1, 3); // μΈλ±μ€ 1(B)μ 3(D) κ΅ν
const moved = arrayMove(items.slice(), 1, 3); // μΈλ±μ€ 1(B)μ μΈλ±μ€ 3 μμΉλ‘ μ΄λ
console.log(swapped); // ['A', 'D', 'C', 'B']
console.log(moved); // ['A', 'C', 'D', 'B']
컬λΌμ λλλλ νμ€ν¬ μ΄λμ onDragEnd
μ΄λ²€νΈ νΈλ€λ¬μμ μ²λ¦¬ν μ μλ€. μλ₯Όλ€μ΄ A 컬λΌ(컨ν
μ΄λ)μ μλ νμ€ν¬λ₯Ό B 컬λΌμΌλ‘ λλκ·Ένλ©΄, λλ‘ν΄μ onDragEnd
νΈλ€λ¬κ° νΈμΆλκΈ° μ κΉμ§ μ΄λ 컬λΌμΌλ‘ μ΄λνλμ§ κ°μ§ν μ μλ€. λλ¬Έμ λλ‘ μμμ΄ λ€λ₯Έ 컨ν
μ΄λλΌλ©΄ Placeholder UIκ° λ λλ§ λμ§ μλλ€.
UIλ₯Ό μ¦μ λ°μνλ €λ©΄ νμ€ν¬λ₯Ό μ»¬λΌ μμμΌλ‘ λλκ·Έν λλ§λ€, ν΄λΉ 컬λΌμ μμ΄ν
λͺ©λ‘μ μ
λ°μ΄νΈν΄μΌ νλ€. μ΄λ₯Ό μν΄ λλκ·Έ μ€μΈ μμ΄ν
μ΄ λλ‘ μ»¨ν
μ΄λ μλ‘ μ΄λν λλ§λ€ νΈμΆλλ onDragOver
μ΄λ²€νΈλ₯Ό νμ©νλ€.
const onDragOver = ({ active, over, delta, activatorEvent }: DragOverEvent) => {
if (!over) return; // λλ‘ μμ λ²μ΄λ¬μ λ
if (active.id === over.id) return; // κ°μ μμΉλ μ€ν΅
const { isActiveTask, isOverTask, isOverColumn } = getDragTypes(active, over);
if (!isActiveTask) return; // Task λλκ·Έκ° μλλ©΄ μ€ν΅
const activeSort = active.data.current?.sortable as TaskSortable;
const overSort = over.data.current?.sortable as TaskSortable;
const sourceTaskId = toTaskId(active.id);
const sourceTaskIdx = activeSort.index; // λλκ·Έλ₯Ό μμν μΉ΄λμ μΈλ±μ€
const sourceColumnId = activeSort.containerId;
// λλ‘ μμμ΄ Task μΉ΄λμ΄λ©΄ ν΄λΉ μΉ΄λμ 컨ν
μ΄λλ 컬λΌμ΄λ―λ‘ overSort.containerId μμ ID νλ
// λλ‘ μμμ΄ μ»¬λΌμ΄λ©΄ over μ체λ 컬λΌμ μ°Έμ‘°νλ―λ‘ over.id μμ ID νλ
const targetColumnId = isOverTask ? overSort.containerId : toColumnId(over.id);
const targetColumn = columns[targetColumnId];
// λλκ·Έ μμ μμΉ(clientY)μ μ΄λ 거리(delta.y)λ₯Ό ν©μ°ν΄μ νμ¬ Y μμΉ κ³μ°
const currentY = (activatorEvent as MouseEvent).clientY + delta.y;
// λλ‘ λμ μΉ΄λμ μΈλ±μ€ (computeTargetTaskIdx ν¨μ μ€λͺ
μ μλ λ΄μ© μ°Έκ³ )
const targetTaskIdx = computeTargetTaskIdx({
isOverColumn,
targetColumn,
overSort,
sourceTaskId,
currentY,
});
moveTask({
sourceTaskId,
sourceColumnId,
targetColumnId,
sourceTaskIdx,
targetTaskIdx,
});
};
νμ€ν¬λ₯Ό λλκ·Ένμ λ λλ‘ μ§μ μ βΆ"μ»¬λΌ μμ μ"(컬λΌμ νμ€ν¬κ° μκ±°λ 컬λΌλ΄ λ€λ₯Έ 곡κ°μ μμΉ), β·"ν΄λΉ 컬λΌμ λ€λ₯Έ νμ€ν¬ μ" μ΄λ κ² λ κ°μ§ κ²½μ°λ‘ λλλ©°, κ° μν©μ λ°λΌ μ²λ¦¬ λ°©μμ΄ λ¬λΌμ§λ€. λν νμ€ν¬λ₯Ό λλκ·Έν λλ§λ€ μλ 3κ°μ§ μνλ₯Ό μ λ°μ΄νΈν΄μΌ νλ€.
- λλκ·Έ μμ΄ν
μ
task.columnId
(λ³κ²½λ μ»¬λΌ IDλ‘ κ΅μ²΄) - λλκ·Έ μ€μΈ μμ΄ν
μ΄ μνλ 컬λΌμ
column.taskIds
- λμΌ μ»¬λΌ λ΄μμ λλκ·Ένλ€λ©΄ μΈλ±μ€ μμλ§ λ³κ²½
- λ€λ₯Έ 컬λΌμΌλ‘ λλκ·Ένλ€λ©΄ ν΄λΉ νμ€ν¬ ID μ κ±°
- λλ‘ λμ 컬λΌμ
column.taskIds
(μΈλ±μ€ μμ λ³κ²½)
μ μνλ₯Ό μ λ°μ΄νΈνκΈ° μν΄μ μμ€ μ»¬λΌ ID, νκ² μ»¬λΌ ID, λλκ·Έ μμ΄ν (νμ€ν¬) ID, μ»¬λΌ λ΄μμ μμλ₯Ό λ³κ²½ν λ μμ΄ν (μμ€/νκ² νμ€ν¬)μ μΈλ±μ€ μ 보λ₯Ό νμΈν΄μΌ νλ€.
- μμ€ μ»¬λΌ ID:
active
κ°μ²΄μsortable.containerId
- νκ² μ»¬λΌ ID:
- λλ‘ λμ - νμ€ν¬:
over
κ°μ²΄μsortable.containerId
(νμ€ν¬μ 컨ν μ΄λλ 컬λΌμ΄λ―λ‘) - λλ‘ λμ - 컬λΌ:
over.id
(μ΄λover
κ°μ²΄λ 컬λΌμ μ°Έμ‘°νλ―λ‘)
- λλ‘ λμ - νμ€ν¬:
- μμ€ νμ€ν¬(λλκ·Έ μμ΄ν
):
active
κ°μ²΄sortable.index
- νκ² νμ€ν¬(λλ‘ μμμ μμΉν μμ΄ν
) 3. λλ‘ λμμ΄ νμ€ν¬μΌ λ:
over
κ°μ²΄sortable.index
4. λλ‘ λμμ΄ μ»¬λΌ μμμΌ λ (컬λΌμ μΉ΄λκ° μκ±°λ, μ»¬λΌ μ/μλμͺ½ μμΉ)- [첫 μ§μ μ΄ μλ λ] 컬λΌ.μμ΄ν λͺ©λ‘μ λλκ·Έ μμ΄ν ID ζ β μ‘°νν μΈλ±μ€ λ°ν
- [첫 μ§μ
μΌ λ] 컬λΌ.μμ΄ν
λͺ©λ‘μ λλκ·Έ μμ΄ν
ID η‘
- λμ 컬λΌμ 첫 λ²μ§Έ νμ€ν¬λ³΄λ€ μμͺ½μΌλ‘ λλκ·Ένμ λ: 첫 λ²μ§Έ μμΉ (μΈλ±μ€ =
0
) - κ·Έ μΈ μν©: λ§μ§λ§ μμΉ (μΈλ±μ€ =
taskIds.length
)
- λμ 컬λΌμ 첫 λ²μ§Έ νμ€ν¬λ³΄λ€ μμͺ½μΌλ‘ λλκ·Ένμ λ: 첫 λ²μ§Έ μμΉ (μΈλ±μ€ =
νκ² νμ€ν¬μ μΈλ±μ€ νμΈ κ³Όμ μ νλ‘μ°μ°¨νΈλ‘ μκ°νν΄λ³΄λ©΄ λ€μκ³Ό κ°λ€.
flowchart TD
subgraph Step1 [Retrieve target index]
A{Drop target<br> Task or Column?}
A -->|Task| B["`Get index from<br>**over.sortable.index**`"]
A -->|Column| C{"`**targetColumn.taskIds** contains the task ID?`"}
C -->|"Yes (Not first entry)"| D["`Retrieve index from **targetColumn.taskIds**`"]
C -->|"No (First entry)"| E{Position?}
E -->|Top Edge| F["`index = **0**<br>(first in taskIds)`"]
E -->|Else| G["`index = **taskIds.length**<br>(last in taskIds)`"]
end
Step1 --> H{Dragging<br> in same column?}
H -->|"Yes (Source Column)"|Y["`Update **column.taskIds**`"]
H -->|"No (Target Column)"|J["`Remove task from source
Add task to target
Update **task.columnId**
`"]
onDragOver
νΈλ€λ¬λ delta
, activatorEvent
κ°μ²΄λ₯Ό μΈμλ‘ λ°λλ€. activatorEvent.clientY
λ λλκ·Έλ₯Ό μμνμ λ y μ’νλ₯Ό λνλ΄κ³ , delta.y
λ μ΄λν 거리λ₯Ό λνλΈλ€. μ΄ λ κ°μ λνλ©΄ νμ¬ λλκ·Έ μ€μΈ μμ΄ν
μ y μ’νλ₯Ό κ³μ°ν μ μλ€.
μ μ΄λ―Έμ§μ Top Boundaryλ 첫 λ²μ§Έ νμ€ν¬ μμͺ½μ κ²½κ³λ₯Ό μλ―Ένλ€. λ§μ½ κ³μ°ν y μ’νκ° μ΄ κ²½κ³λ³΄λ€ μμΌλ©΄(Drag Position 1) λλκ·Έ μμ΄ν μ 첫 λ²μ§Έ μμ΄ν μΌλ‘ μμΉμν€κ³ (μΈλ±μ€ = 0), κ·Έ μΈμ(Drag Position 2) λ§μ§λ§ μμ΄ν (taskIds.length)μΌλ‘ μμΉμν¨λ€.
export const computeTargetTaskIdx = ({
isOverColumn,
targetColumn,
overSort,
sourceTaskId,
currentY,
topBoundary = 200,
}: ComputeTargetTaskIdxParams): number => {
// λλ‘ λμμ΄ νμ€ν¬μΌ λ
if (!isOverColumn) return overSort.index;
// λλ‘ λμμ΄ μ»¬λΌ μμμΌ λ (컬λΌμ μΉ΄λκ° μκ±°λ μ»¬λΌ μ/μλμͺ½ μμΉ)
const index = targetColumn.taskIds.indexOf(sourceTaskId);
// μ»¬λΌ μμ μ§μ
β 첫λ²μ§Έ/λ§μ§λ§ μμΉμ μΈλ±μ€λ‘ λμ 컬λΌμ μμ΄ν
λͺ©λ‘μ μ
λ°μ΄νΈν μνμμ λ€μ μμ§μμ λ
if (index !== -1) return index;
// λμ 컬λΌμ 첫λ²μ§Έ μΉ΄λ μμΉλ³΄λ€ μλ‘ λλκ·Έ νμ λ 첫λ²μ§Έλ‘, κ·Έ μΈμ λ§μ§λ§ μΈλ±μ€λ‘ μ€μ
return currentY < topBoundary ? 0 : targetColumn.taskIds.length;
};
- ν¨λ(Panning): νλ©΄μ ν°μΉν μνμμ λλκ·Ένμ¬ μ΄λνλ μ μ€μ²
- νμΉμ€(Pinch Zoom): λ μκ°λ½μ λͺ¨μΌκ±°λ λ²λ €μ νλ©΄μ μΆμ/νλνλ μ μ€μ²
dnd-kitμ μΌμ(sensor)λ λλκ·Έμ€λλ‘ μνΈμμ©μ κ°μ§νκ³ μ μ΄νλ μΆμν λ μ΄μ΄λ€. κΈ°λ³Έμ μΌλ‘ Pointer, Keyboard μΌμκ° νμ±νλλ©°, useSensors
ν
μ ν΅ν΄ λ€λ₯Έ μΌμλ‘ λ³κ²½ν μ μλ€. delay
, distance
κ°μ μ μ½ μ‘°κ±΄μ μΆκ°ν΄μ λλκ·Έ νμ±μ λ°©μ§ν μλ μλ€.
λ§μ½ λλκ·Έ νΈλ€(listenersκ° ν λΉλμ΄ μλ μμ) μμ λ²νΌμ΄ μλ€λ©΄, ν΄λ¦νλ μκ° λλκ·Έκ° νμ±νλΌμ λ²νΌ ν΄λ¦μ΄ λΆκ°λ₯ν΄μ§λ€. distance
μ μ½ μ‘°κ±΄μ μΆκ°νλ©΄ μ΄λ¬ν λ¬Έμ λ₯Ό ν΄κ²°ν μ μλ€.
λͺ¨λ°μΌμ Touch μΌμμ delay
, tolerance
μ μ½ μ‘°κ±΄μ μΆκ°ν΄μ μΌμ μκ° μ΄μ ν°μΉν΄μΌλ§ λλκ·Έκ° νμ±νλλλ‘ ν μ μλ€. μΌλ°μ μΈ λ‘±νλ μ€ λκΈ° μκ°μ 250msκ° μ λΉνλ€.
Pointer μΌμλ λ°μ€ν¬ν± ν΄λ¦κ³Ό λͺ¨λ°μΌ ν°μΉ μ΄λ²€νΈλ ν¨κ» μ²λ¦¬νκΈ° λλ¬Έμ λ°μ€ν¬ν±, λͺ¨λ°μΌ λ€λ₯Έ μ μ½ μ‘°κ±΄μ μ μ©νλ €λ©΄ Mouse, Touch μΌμλ₯Ό κ°κ° μ¬μ©ν΄μΌ νλ€.
// λ°μ€ν¬ν± μ΅μ ν
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: {
// λλκ·Έ μμμ μν΄ μμ ν΄λ¦ ν 컀μλ₯Ό μ΄λμμΌμΌ νλ μ΅μ 거리(px)
distance: 10, // ν΄λ¦ ν 10px μ΄μ μμ§μ¬μΌ λλκ·Έ μμ (μλμΉ μμ ν΄λ¦ λ°©μ§)
},
});
// λͺ¨λ°μΌ μ΅μ ν
const touchSensor = useSensor(TouchSensor, {
activationConstraint: {
// λλκ·Έ μμμ μν΄ ν°μΉλ₯Ό μ μ§ν΄μΌ νλ μ΅μ μκ°(ms)
delay: 250, // 250ms ν°μΉ μ μ§ νμ (μΌλ°μ μΈ λͺ¨λ°μΌ μ±μ λ‘±νλ μ€ λκΈ° μκ°)
// delay λμ νμ©λλ μ΅λ μ΄λ 거리(px). μ΄κ³Όμ λλκ·Έ μ·¨μλ¨.
tolerance: 5, // 5px μ΄λ΄ μμ§μ νμ© (μλ¨λ¦Όμ΄λ λ―ΈμΈν μμ§μ νμ©)
},
});
const sensors = useSensors(touchSensor, mouseSensor);
return <DndContext sensors={sensors} >
touch-action
CSS μμ±μ ν°μΉ κΈ°λ° μ
λ ₯ μ₯μΉμμ νΉμ μμκ° μ΄λ€ κΈ°λ³Έ ν°μΉ λμ(ν¨λ, νμΉμ€ λ±)μ μνν μ§ κ²°μ νλ€. ν°μΉ μΌμλ₯Ό μ¬μ©νλ€λ©΄ touch-action: manipulation
μΌλ‘ μ€μ νλ κ²μ κΆμ₯νκ³ μλ€.
.draggable-item {
/* ν¨λ/νμΉμ€μ νμ©νμ§λ§ λλΈν νλ κ°μ λΉνμ€ μ μ€μ²λ λΉνμ±ννμ¬ ν°μΉ λλκ·Έ λ°μμ± κ°μ */
touch-action: manipulation;
/* ν
μ€νΈ μ ν λ°©μ§ */
user-select: none;
/* ν
μ€νΈ μ ν λ°©μ§ for iOS Safari */
-webkit-user-select: none;
/* ν
μ€νΈ μ ν λ°©μ§ for Firefox */
-moz-user-select: none;
}
μ°Έκ³ λ‘ Pointer μΌμλ₯Ό μ¬μ©νλ©΄ λͺ¨λ°μΌ νκ²½μμ λλκ·Έν λ λΈλΌμ°μ κΈ°λ³Έ λμμΌλ‘ μΈν΄ νλ©΄λ ν¨κ» μ€ν¬λ‘€λλ λ¬Έμ κ° λ°μνλ€. ν΄κ²°νλ €λ©΄ λλκ·Έν μμμ touch-action: none
μ μ€μ νμ¬ λΈλΌμ°μ μ κΈ°λ³Έ ν°μΉ λμμ λΉνμ±νν΄μΌ νλ€. (μ°Έκ³ λ§ν¬)
Next.jsλ₯Ό μ¬μ©νλ€λ©΄ Warning: Prop aria-describedby did not matchβ¦ κ°μ νμ΄λλ μ΄μ
μλ¬κ° λ°μν μ μλ€. μλ² μ¬μ΄λ λ λλ§ μ μμ±ν DndContext IDμ ν΄λΌμ΄μΈνΈμμ μμ±ν IDκ° μΌμΉνμ§ μμμ λ°μνλ λ¬Έμ . 리μ‘νΈ useId
ν
μ μ΄μ©νμ¬ μΌκ΄λ IDλ₯Ό μμ±νλ©΄ ν΄κ²°ν μ μλ€. (μ°Έκ³ μ΄μ #926)
import { useId } from 'react';
const id = useId();
return <DndContext id={id} />;
μμ΄ν
μ μ¬κΈ°μ κΈ° λλκ·Ένλ€λ³΄λ©΄ κ°λ Maximum update depth exceeded(μ»΄ν¬λνΈ λ¬΄ν 루ν) μλ¬κ° λ°μνλ€. μ΄ μλ¬λ Sortable ν리μ
μ μ¬μ©ν λ λ°μνλ€. onDragOver
νΈλ€λ¬μ μλ μν λ³κ²½ ν¨μμ 0ms λλ°μ΄μ€λ₯Ό μ μ©νλ©΄ ν΄κ²°ν μ μλ€. (μ°Έκ³ μ΄μ #900)
import { useDebouncedCallback } from 'use-debounce';
const moveTask = useKanbanStore.use.moveTask();
const debouncedMoveTask = useDebouncedCallback(moveTask, 0);
// ...
const onDragOver = ({ active, over, delta, activatorEvent }: DragOverEvent) => {
// ...
debouncedMoveTask({
sourceTaskId,
sourceColumnId,
targetColumnId,
sourceTaskIdx,
targetTaskIdx,
});
};
- μΉΈλ° λμμΈ: https://kanban-app-jay.netlify.app
- μ μ ν°νΈ: Pretendard
- λλκ·Έμ€λλ‘: Dnd Kit Sortable