Skip to content

romantech/simple-kanban

Repository files navigation

Simple Kanban

desktop-demo

데λͺ¨ μ‚¬μ΄νŠΈ: 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

μ£Όμš” κΈ°λŠ₯

  1. 칸반 μš”μ†Œ 관리: λ³΄λ“œ/컬럼/μž‘μ—… CRUD
  2. λ“œλž˜κ·Έμ•€λ“œλ‘­ 지원: 컬럼, μž‘μ—… 이동
  3. AI 기반 ν•˜μœ„ μž‘μ—… μžλ™ 생성
  4. ν•˜μœ„ μž‘μ—… μ§„ν–‰ ν˜„ν™© ν‘œμ‹œ
  5. κΈ°λ³Έ 컬럼 ν…œν”Œλ¦Ώ 제곡
  6. λ³΄λ“œ 검색

κ΅¬ν˜„ κ³Όμ • λͺ©μ°¨

칸반 데이터 λͺ¨λΈ

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 "νƒœμŠ€ν¬ μ—…λ°μ΄νŠΈ μ‹œκ°„"
    }
Loading
  • 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κ°€μ§€ μƒνƒœλ₯Ό μ—…λ°μ΄νŠΈν•΄μ•Ό ν•œλ‹€.

  1. λ“œλž˜κ·Έ μ•„μ΄ν…œμ˜ task.columnId (λ³€κ²½λœ 컬럼 ID둜 ꡐ체)
  2. λ“œλž˜κ·Έ 쀑인 μ•„μ΄ν…œμ΄ μ†ν–ˆλ˜ 컬럼의 column.taskIds
    1. 동일 컬럼 λ‚΄μ—μ„œ λ“œλž˜κ·Έν–ˆλ‹€λ©΄ 인덱슀 μˆœμ„œλ§Œ λ³€κ²½
    2. λ‹€λ₯Έ 컬럼으둜 λ“œλž˜κ·Έν–ˆλ‹€λ©΄ ν•΄λ‹Ή νƒœμŠ€ν¬ ID 제거
  3. λ“œλ‘­ λŒ€μƒ 컬럼의 column.taskIds (인덱슀 μˆœμ„œ λ³€κ²½)

μœ„ μƒνƒœλ₯Ό μ—…λ°μ΄νŠΈν•˜κΈ° μœ„ν•΄μ„  μ†ŒμŠ€ 컬럼 ID, νƒ€κ²Ÿ 컬럼 ID, λ“œλž˜κ·Έ μ•„μ΄ν…œ(νƒœμŠ€ν¬) ID, 컬럼 λ‚΄μ—μ„œ μˆœμ„œλ₯Ό λ³€κ²½ν•  두 μ•„μ΄ν…œ(μ†ŒμŠ€/νƒ€κ²Ÿ νƒœμŠ€ν¬)의 인덱슀 정보λ₯Ό 확인해야 ν•œλ‹€.

컬럼 ID 확인

  1. μ†ŒμŠ€ 컬럼 ID: active 객체의 sortable.containerId
  2. νƒ€κ²Ÿ 컬럼 ID:
    1. λ“œλ‘­ λŒ€μƒ - νƒœμŠ€ν¬: over 객체의 sortable.containerId (νƒœμŠ€ν¬μ˜ μ»¨ν…Œμ΄λ„ˆλŠ” μ»¬λŸΌμ΄λ―€λ‘œ)
    2. λ“œλ‘­ λŒ€μƒ - 컬럼: over.id (μ΄λ•Œ over κ°μ²΄λŠ” μ»¬λŸΌμ„ μ°Έμ‘°ν•˜λ―€λ‘œ)

μ•„μ΄ν…œ 인덱슀 확인

  1. μ†ŒμŠ€ νƒœμŠ€ν¬(λ“œλž˜κ·Έ μ•„μ΄ν…œ): active 객체 sortable.index
  2. νƒ€κ²Ÿ νƒœμŠ€ν¬(λ“œλ‘­ μ˜μ—­μ— μœ„μΉ˜ν•œ μ•„μ΄ν…œ) 3. λ“œλ‘­ λŒ€μƒμ΄ νƒœμŠ€ν¬μΌ λ•Œ: over 객체 sortable.index 4. λ“œλ‘­ λŒ€μƒμ΄ 컬럼 μ˜μ—­μΌ λ•Œ (μ»¬λŸΌμ— μΉ΄λ“œκ°€ μ—†κ±°λ‚˜, 컬럼 μœ„/μ•„λž˜μͺ½ μœ„μΉ˜)
    1. [첫 μ§„μž…μ΄ 아닐 λ•Œ] 컬럼.μ•„μ΄ν…œ λͺ©λ‘μ— λ“œλž˜κ·Έ μ•„μ΄ν…œ ID ζœ‰ β†’ μ‘°νšŒν•œ 인덱슀 λ°˜ν™˜
    2. [첫 μ§„μž…μΌ λ•Œ] 컬럼.μ•„μ΄ν…œ λͺ©λ‘μ— λ“œλž˜κ·Έ μ•„μ΄ν…œ 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**
		`"]

Loading

λ“œλž˜κ·Έ μ•„μ΄ν…œ Y μ’Œν‘œ 계산

onDragOver ν•Έλ“€λŸ¬λŠ” delta, activatorEvent 객체λ₯Ό 인자둜 λ°›λŠ”λ‹€. activatorEvent.clientYλŠ” λ“œλž˜κ·Έλ₯Ό μ‹œμž‘ν–ˆμ„ λ•Œ y μ’Œν‘œλ₯Ό λ‚˜νƒ€λ‚΄κ³ , delta.yλŠ” μ΄λ™ν•œ 거리λ₯Ό λ‚˜νƒ€λ‚Έλ‹€. 이 두 값을 λ”ν•˜λ©΄ ν˜„μž¬ λ“œλž˜κ·Έ 쀑인 μ•„μ΄ν…œμ˜ y μ’Œν‘œλ₯Ό 계산할 수 μžˆλ‹€.

drag-position.png

μœ„ μ΄λ―Έμ§€μ˜ 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,
  });
};

참고 자료

μŠ€ν¬λ¦°μƒ·

desktop-screenshot mobile-screenshot

Contributors 2

  •  
  •