본문 바로가기

javascript

javascript 드래드 앤 드롭 이벤트 연습하기[dragover | drag-over | dragleave | drop]

안녕하시렵니까?

최근 개인 사이드 프로젝트로 노션같은 프로젝트 관리 서비스를 한번만들어 보고있는데 말입니다.

 

그 왜 노션쓰다보면 보드 있지않습니까. 

그거 만들려고 하는데 말입니다. 

 

하....이거 감도 안오는거 있지않습니까?

명색의 프론트개발자라고 떠들고다니는 양반이 이런거 하나 못해서 말이야.. 

 

전 회사에서 너무 물경력으로 있긴했음... 정신차리고 열심히 살아야지...

 

그래서 깃에 프로젝트 하나 간단한거 파서 연습한번 해봤음다.

뭐 다른 프레임워크 그런거 안쓰고 걍 html/css 로 UI딸깍 했습니다. 

#index.html
<!doctype html>
<html lang="ko">
<head>
    <meta charset="UTF-8" />
    <title>Drag & Drop 연습</title>
    <link rel="stylesheet" href="css/style.css" />
</head>
<body>
<h1>Drag & Drop 연습</h1>

<div class="board-wrapper">
    <div class="column" data-column-id="todo">
        <div class="column-header">할 일</div>
        <div class="card" draggable="true" data-id="1">HTML 구조 만들기</div>
        <div class="card" draggable="true" data-id="2">CSS로 스타일링</div>
        <div class="card" draggable="true" data-id="3">JS로 드래그 이벤트</div>
    </div>

    <div class="column" data-column-id="doing">
        <div class="column-header">진행 중</div>
        <div class="card" draggable="true" data-id="4">Drag & Drop 연습</div>
    </div>
</div>

<script src="script/script.js"></script>
</body>
</html>

 

#style.css

@charset "UTF-8";

* {
    box-sizing: border-box;
}

body {
    margin: 0;
    padding: 40px;
    font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    background: #f3f4f6;
}

h1 {
    margin-bottom: 20px;
    font-size: 24px;
}

.board-wrapper {
    display: flex;
    gap: 16px;
}

.column {
    flex: 1;
    background: white;
    border-radius: 8px;
    padding: 12px;
    box-shadow: 0 2px 6px rgba(0,0,0,0.08);
    min-height: 200px;
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.column-header {
    font-size: 14px;
    font-weight: 600;
    margin-bottom: 8px;
    color: #4b5563;
}

.card {
    padding: 8px 10px;
    background: #e5e7eb;
    border-radius: 6px;
    cursor: grab;
    user-select: none;
    transition: transform 0.1s ease, box-shadow 0.1s ease, opacity 0.1s ease;
}

.card:active {
    cursor: grabbing;
}

.card.dragging {
    opacity: 0.5;
    box-shadow: 0 6px 16px rgba(0,0,0,0.15);
    transform: scale(1.03);
}

.placeholder {
    height: 36px;
    border-radius: 6px;
    border: 2px dashed #9ca3af;
    margin: 2px 0;
}

.column.drag-over {
    background: #f9fafb;
    outline: 2px dashed #60a5fa;
    outline-offset: 2px;
}

 

div에 draggable=true 옵션을 넣어줘야 드래그가 가능하다~ 이말씀이야

나머지 UI랑 CSS는 알아서 확인해보시고ㅇㅇ

 

 

// 모든 카드 선택
const cards = document.querySelectorAll(".card");
// 모든 컬럼 선택
const columns = document.querySelectorAll(".column");

let draggingCard = null;
let placeholder = null;

cards.forEach(card => {
    // 드레그 스타트 함수 이벤트 리스너
    card.addEventListener("dragstart", (e) => {
        // 드래그 중인 카드 변수에 담고
        draggingCard = card;
        // dom 에 class name 업데이트 class card => card dragging
        card.classList.add("dragging");

        // 기본 드래그 이미지는 그대로 두고, 데이터만 세팅
        // 이 작업은 이동 작업이라고 브라우저에 선언 ,
        // "copy" → 복사
        // "move" → 이동
        // "link" → 링크
        e.dataTransfer.effectAllowed = "move";

        // 선택된 카드 정보를 짐처럼 들고 다님.
        e.dataTransfer.setData(
            "text/plain",
            JSON.stringify({
                id: card.dataset.id,
                fromColumn: card.parentElement.dataset.columnId
            })
        );
        // 카드에서 하는건 현재 어떤 카드가 드래드 되어있는지 class name 을 변경하고(css 영향), 해당 카드의 인덱스를 저장.
    });

    //드래그 이벤트 종료 리스너
    card.addEventListener("dragend", () => {
        //dragging class에서 제거 class card dragging => card
        draggingCard.classList.remove("dragging");
        // 드래그 카드 제거.
        draggingCard = null;

        // placeholder -> 드래그했을때 들어갈 자리에 미리 보여지던 카드 제거
        if (placeholder) {
            placeholder.remove();
            placeholder = null;
        }

        //
        columns.forEach(col => col.classList.remove("drag-over"));
    });
});

columns.forEach(column => {
    // 드래그 요소가 각 컬럼 위에 위치하면 발생하는 이벤트리스너
    column.addEventListener("dragover", (e) => {
        e.preventDefault(); // drop 허용
        e.dataTransfer.dropEffect = "move";

        // column class 에 drag-over 추가
        column.classList.add("drag-over");
        //현재 마우스 좌표 다음 카드 요소 확인( 현재 컬럼 , y 좌표)
        const afterElement = getDragAfterElement(column, e.clientY);

        if (!placeholder) {
            placeholder = document.createElement("div");
            placeholder.classList.add("placeholder");
        }

        //뒤에 카드가 더 이상 없을때
        if (afterElement == null) {
            // 맨뒤에 placeholder
            column.appendChild(placeholder);
        } else {
            // 아니면 afterElement 앞에 넣기
            column.insertBefore(placeholder, afterElement);
        }
    });

    // 컬럼 밖으로 드래그가 나갔을 때
    column.addEventListener("dragleave", (e) => {
        // 같은 걸런 내에서 이동 시 무시
        if (e.currentTarget.contains(e.relatedTarget)) return;
        // column class 에 drag-over 제거
        column.classList.remove("drag-over");
        //placeholder 가 있고, placeholder의 부모와 컬럼이 같으면 제거
        if (placeholder && placeholder.parentElement === column) {
            placeholder.remove();
        }
    });

    //드래그 끝났을때 리스너 추가
    column.addEventListener("drop", (e) => {
        e.preventDefault();
        column.classList.remove("drag-over");

        if (!draggingCard) return;

        // placeholder 위치에 카드 삽입
        if (placeholder && placeholder.parentElement === column) {
            column.insertBefore(draggingCard, placeholder);
            placeholder.remove();
            placeholder = null;
        } else {
            column.appendChild(draggingCard);
        }

        const raw = e.dataTransfer.getData("text/plain");
        if (raw) {
            const data = JSON.parse(raw);
            console.log("드롭 데이터:", data);
            console.log("toColumn:", column.dataset.columnId);
        }
    });
});

// 마우스 위치 기준으로 어느 카드 뒤에 들어갈지 계산
function getDragAfterElement(container/* 컬럼 */, y /*y좌표*/ ) {
    // 카드에서 dragging 중인 요소 제외한 카드 리스트
    const draggableElements = [...container.querySelectorAll(".card:not(.dragging)")];


    return draggableElements.reduce(
        (closest/* 지금까지 선택된 요소 */, child) => {
            const box = child.getBoundingClientRect();
            // offset = 현재 내 커서의 y좌표 - 카드의 top 좌표 - box.height / 2 => 카드의 중앙 위치
            const offset = y - box.top - box.height / 2;

            // 마우스가 이전 카드의 중아보다 위에있고 이전의 찾은 카드보다 더 가까우면
            if (offset < 0 && offset > closest.offset) {
                // 지금 찾은 카드를 리턴
                return { offset, element: child };
            } else {
                // 이전에 찾은 카드 그대로 리턴
                return closest;
            }
        },
        { offset: Number.NEGATIVE_INFINITY, element: null } // 초기값은 설정 안함.
    ).element;
}

 

스크립트 부분입니다.

자, 하나하나 뜯고맛보고 즐겨봅시다

 

const cards = document.querySelectorAll(".card");
const columns = document.querySelectorAll(".column");

card 랑 컬럼 리스트 가져오는 코드입니다. 

뭐 react나 next 에서 사용한다면 state로 관리하는 부분.

card.addEventListener("dragstart", (e) => {
        draggingCard = card;
       
        card.classList.add("dragging");
        e.dataTransfer.effectAllowed = "move";

        e.dataTransfer.setData(
            "text/plain",
            JSON.stringify({
                id: card.dataset.id,
                fromColumn: card.parentElement.dataset.columnId
            })
        );
    });

dragstart 는 문자 그대로 드래그가 시작될때 동작하는 함수입니다~

드래그중인 카드를 변수로 담고,

class name 에 dragging 추가

effectAllowed = "move"는 “이 드래그는 이동(move) 작업만 허용해”라고 드래그 시작 쪽에서 선언하는

(일종의 DOM생태계 관리)

 

setData를 통해 선택된 카드정보를 짐처럼 들고 다님. 

아니 그럼 draggingCard 는왜 담은거임??

=> draggingCard 드래그 중인 DOM 요소 자체를 다른 이벤트 리스너(drop, dragend)에서 직접 조작하기 위해 전역 변수로 들고 있는 .

column.addEventListener("dragover", (e) => {
        e.preventDefault(); 
        e.dataTransfer.dropEffect = "move";

        column.classList.add("drag-over");
        const afterElement = getDragAfterElement(column, e.clientY);

        if (!placeholder) {
            placeholder = document.createElement("div");
            placeholder.classList.add("placeholder");
        }

        if (afterElement == null) {
            column.appendChild(placeholder);
        } else {
            column.insertBefore(placeholder, afterElement);
        }
    });

 

dragover - 해당 요소위에서 드래그 할 때 발생하는 이벤트 리스너.

dropEffect = "move"는 “여기다 놓으면 이동이 일어날 거야”라고 드롭 위치에서 선언하는 것.

const afterElement = getDragAfterElement(column, e.clientY);

현재 마우스 위치 기준으로,

그보다 위에 있는 카드들 중에서 가장 가까운 카드 하나를 찾아서,

그 앞에 placeholder를 넣는 함수

column.addEventListener("dragleave", (e) => {
    if (e.currentTarget.contains(e.relatedTarget)) return;
    column.classList.remove("drag-over");
    if (placeholder && placeholder.parentElement === column) {
        placeholder.remove();
    }
});

dragleave는 해당 요소에서 드래그가 나갔을때 발생하는 것으로 기존에 담겨있던 클래스를 지워주고.

placeholder가 있다면 해당 요소를 제거해준다.

column.addEventListener("drop", (e) => {
    e.preventDefault();
    column.classList.remove("drag-over");

    if (!draggingCard) return;

    // placeholder 위치에 카드 삽입
    if (placeholder && placeholder.parentElement === column) {
        column.insertBefore(draggingCard, placeholder);
        placeholder.remove();
        placeholder = null;
    } else {
        column.appendChild(draggingCard);
    }

    const raw = e.dataTransfer.getData("text/plain");
    if (raw) {
        const data = JSON.parse(raw);
        console.log("드롭 데이터:", data);
        console.log("toColumn:", column.dataset.columnId);
    }
});

마지막으로 drop 드래그가 끝날때 발생하는 이벤트 리스너.

 

드래그 중인 요소가 없다면 그냥 반환.

 

placeholder && placeholder.parentElement === column

placeholder 가 있고 그거의 부모 컬럼이 지금 컬럼과 같다면 insertBefore로 드래그 중인 카드요소를 placeholder자리에 넣어줌

 

그게 아니라면 그래그중인 요소를  node에 추가

 

전체적인 Drag & Drop 흐름

1. dragstart – 드래그 시작: 어떤 요소가 드래그 중인지 기록, 데이터 세팅

2. dragover – 드래그 중: e.preventDefault() 해야 drop 가능, 위치 계산

3. dragleave – 영역 밖으로 나갈 때: 스타일 / placeholder 정리

4. drop – 실제로 놓았을 때: DOM 재배치, 필요한 데이터 처리

5. dragend – 드래그가 완전히 끝난 뒤 정리: 클래스 제거, 변수 초기화

 

이렇게 한번 드래그요소들 이용해서 연습해보고

진행중인 프로젝트에서 gpt가 만들어준코드 다 지워버리고 다시 코딩해서 원하는 로직대로 동작하도록 완성.

 

드래드앤드롭 연습 git : https://github.com/Choi-jae-min/dragAndDropEvent

 

GitHub - Choi-jae-min/dragAndDropEvent: 프론트 드래그 앤 드롭 연습

프론트 드래그 앤 드롭 연습. Contribute to Choi-jae-min/dragAndDropEvent development by creating an account on GitHub.

github.com

사이드 프로젝트 git : https://github.com/Choi-jae-min/TaskGround

 

GitHub - Choi-jae-min/TaskGround: 팀이 이슈/작업을 생성·배정·추적할 수 있는 웹앱

팀이 이슈/작업을 생성·배정·추적할 수 있는 웹앱. Contribute to Choi-jae-min/TaskGround development by creating an account on GitHub.

github.com