[JS] 드래그 앤 드롭으로 엘리먼트를 특정 위치에 배치하기 #2

[JS] 드래그 앤 드롭으로 엘리먼트를 특정 위치에 배치하기 #2

자바스크립트 드래그 앤 드롭 씨리-즈


이 글에서는 본격적으로 "드래그 앤 드롭으로 특정 위치에 엘리먼트 배치하기", 즉 이번 주제의 핵심을 다뤄보려고 합니다! 대부분의 로직은 저번 글에서 작성한 이벤트들의 핸들러 내에 구현합니다.

어디선가 많이 본 화면이라면, 감사합니다. 이전 글을 읽고 오셨군요!

이 글에서도 계속해서 실제 SW마에스트로에서 개발을 진행 중인 프로젝트의 화면을 예시로 사용하겠습니다.
왼쪽의 "스티커"들, 즉 드래그 대상draggable 요소들은 .sticker 라는 클래스를 가지며 data-sticker="<sticker-id>" 형태로 스티커마다 부여된 고유 ID를 dataset에 포함하고 있고,
오른쪽의 "편지 영역", 즉 드롭 영역dropzone#letter-container 라는 ID를 갖는 걸로 가정할게요.

드롭... 드랍... 드랍 더 빗트...

아니, 등록한 이벤트가 한 두개가 아닌데 대체 어디부터 시작해야 하는 걸까요? 직관적으로 생각해봅시다. 아무래도 마우스로 드래그하다가 마우스 버튼을 놓는 순간, 즉 "드롭"할 때 뭔가가 작동되어야한다는 느낌이 듭니다. 저만 그런가요?

뭐, 어떻게든 되지 않을까요? 우선 drop 이벤트 핸들러부터 들들 볶아봅시다.

/* 편지 영역 drop 이벤트 등록 + 핸들러 */
document.querySelector("#letter-container").addEventListener("drop", (event) => {
    event.preventDefault(); // 추가적인 이벤트 발생 방지
    event.stopPropagation(); // 하위 element로의 이벤트 전파 방지
    
    // 여기에 무언가가 들어가야 할 것 같은데...
});

저 아름다운 여백을 가장한 소심한 주석의 공간을 어떤 로직으로 채워야할까요? 일단 "스티커를 붙인다"라는 행위의 순서를 공학적으로(...?) 해석해봅시다.

  1. 사용자는 임의의 스티커 하나를 집어든다.
  2. 사용자는 집어든 스티커를 편지가 있는 곳으로 가져온다.
  3. 사용자는 편지라는 공간 내 스티커를 붙이고자 하는 임의의 위치에 스티커를 가져다 댄다.
  4. 스티커를 편지에 접착한다.

저희는 이미 스티커 엘리먼트Elementdraggable 속성을 주는 것으로 집어들 수 있도록 만들었습니다. 1번 패쓰.

사용자가 스티커를 집어들고나면 붙일만한 곳으로 스티커를 이동시킬건데, 그 과정은 전적으로 마우스(또는 대체 포인팅 장치)의 역할이면서 웹 브라우저의 역할입니다. 저희가 이 과정에 개입할 것은 딱히 없는 것 같네요. 2번 패쓰.

3번을 생각해보면, 사용자가 스티커를 이동시키고 나면 스티커는 결국 어떠한 위치에 붙여질 운명이 되어버릴텐데, 여기서 문제는 '어떠한 위치'를 어떠한 방법으로 구해내야 하는가가 될 것 같네요.
현실에서도 스티커를 붙이려고 할 때 정확히 그 자리에 붙지, 붙이려는 순간에 손가락에 붙어있던 스티커가 뿅❇️ 하고 사라졌다가 다른 곳에서 그 스티커가 뾰뵹🪄 하고 나타나 그 자리에 붙여지지는 않으니까요.

(그런 경험이 있다면 엔트로피 보존 법칙에 위배되지는 않더라도 뉴턴의 운동법칙이 위배되었을 가능성이 있으므로 즉시 전문가와 상의하시길 바랍니다. 전문가에는 정신건강의학과 전문의도 포함됩니다.)

4번은 단순합니다. 3번에서 알아낸 정확한 위치에 스티커 엘리먼트를 복사 붙이기만 하면 돼요!

위치를 구한다는 것에 추가 설명을 하자면, 드래그 앤 드롭 관련 이벤트에는 DragEvent 인터페이스를 구현하는 이벤트 오브젝트가 존재하고, 이 DragEvent 인터페이스는 MouseEvent 인터페이스를 상속받고 있기에, 다행히도 마우스 포인터와 관련된 이벤트 데이터를 가져올 수 있을 것 같네요!
이벤트 오브젝트에서 제공되는 마우스 포인터 위치로 pageX/Y, offsetX/Y, clientX/Y, screenX/Y 등을 사용할 수 있는데, 이 글에서 이들의 차이점을 따로 설명하진 않겠습니다. 또는 간단하고 명료하게 각 프로퍼티의 뜻을 정리한 글을 참고하세요.

여기에서는 편지 영역의 내용이 길어져 스크롤을 한 경우까지 고려해야 하므로, 스크롤 길이까지 포함하여 계산하는 pageX/Y를 사용하는 것이 적당할 것 같네요!
이벤트 타깃 위치와 상대적인 offsetX/Y를 사용할 수 있겠지만, 만약 타깃 컨테이너 내에 하위 엘리먼트가 있는데 그 하위 엘리먼트 영역에다가 드롭을 하게 되면, 이벤트 타깃이 그 엘리먼트로 설정되어 컨테이너즉, dropzone가 아닌 하위 엘리먼트에 상대적인 좌표값이 나와버리게 됩니다.
그러면 추후 스티커 배치 좌표 계산이 잘못되어버리니 차라리 페이지 전체에 상대적인 좌표값을 쓰는게 나아요. 물론 실제 개발을 진행할 때에는 상황에 따라 적절한 프로퍼티를 사용하셔야됩니다!

참고로, HTML/CSS에서 좌표 기준점Margin을 제외한 DOM 엘리먼트의 좌측 상단입니다. (box-sizing: border-box인 경우 Border 영역을 포함합니다. box-sizing에 대해서는 MDN 문서를 참고하세요.)

DOM 엘리먼트 좌표 기준 이해를 돕기 위한 그림 (Illust by sorakumo)

좌표 기준으로부터, X축이 양의 값이면 우측으로, Y축이 양의 값이면 하단으로 진행됩니다.
즉, (+X, +Y)는 좌표 기준으로부터 우측 하단에 위치하겠죠!

일단 코드를 보면서 합시다

본론으로 돌아와서, 일단 1차적으로 구현된 코드를 가지고 설명을 이어서 해보겠습니다!

<!-- HTML -->

<!-- 스티커 아이템(드래그 대상)이 들어있는 컨테이너 -->
<div id="decor-container">
    <!-- 개별 스티커 아이템 -->
    <img src="**" alt="**" class="sticker" draggable="true" />
    ...
</div>

<!-- 편지지(드롭존) 컨테이너 -->
<div id="letter-container">
    <!-- 편지지에 붙여질 스티커를 담을 컨테이너, 아무것도 들어있지 않은 상태 -->
    <div id="letter-decor-container"></div>
	
    ...
</div>
/* CSS */

.sticker {
    /* 드래그 대상인 스티커이든, 편지지에 붙여진 스티커이든 모두 동일한 크기를 가지도록 합니다. */
    width: 96px;
    height: 96px;
    ...
}

#letter-container #letter-decor-container {
    /* 자식 엘리먼트의 절대 위치가 #letter-container에 상대적이게 만들기 위해 position을 설정해줍시다. */
    position: relative;
    ...
}

#letter-container #letter-decor-container .sticker {
    /* 편지지에 붙여진 스티커는 left, top을 이용하여 절대 위치에 배치할 것이므로 position을 설정해줍시다. */
    position: absolute;
    ...
}
/* JavaScript */

document.querySelectorAll("#decor-container .sticker").forEach((element) => {
    /* #decor-container 안에 존재하는 모든 .sticker에 dragstart 이벤트 핸들러 등록 */
    /* 이렇게 해야 드래그가 허용되지 않는 엘리먼트도 드래그가 가능해져요. */
    element.addEventListener("dragstart", (event) => {
        // 스티커를 드래그하기 시작하면 DataTransfer에 드래그하고 있는 스티커의 ID를 저장합니다.
    	event.dataTransfer.setData("text/plain", event.target.dataset["sticker"]);
    });
});

document.querySelector("#letter-container").addEventListener("dragover", (event) => {
    /* 편지지(드롭존) 컨테이너에 dragover 이벤트 핸들러 등록 */
    /* 마찬가지로 빈 핸들러라도 등록을 해야 이 영역에 드롭이 가능해져요. */
    event.preventDefault();
    event.stopPropagation();
});

document.querySelector("#letter-container").addEventListener("drop", (event) => {
    /* 편지 컨테이너(드롭존)에 drop 이벤트 핸들러 등록 */
    event.preventDefault();
    event.stopPropagation();
    
    // 편지지(드롭존) 컨테이너
    const letterContainer = document.querySelector("#letter-container");
    // X축 상대 좌표 계산
    const relativeX = event.pageX - letterContainer.offsetLeft;
    // Y축 상대 좌표 계산
    const relativeY = event.pageY - letterContainer.offsetTop;
    
    /* 스티커 배치하기! */
    // 스티커를 "붙이려면(= 배치하려면)" #letter-decor-container 안에 엘리먼트를 생성 또는 복사해주면 됩니다.
    // 스티커 dragstart 이벤트에서 저장했던 스티커 ID를 이용해 원 스티커 엘리먼트를 알아냅시다.
    const stickerId = event.dataTransfer.getData("text/plain");
    const stickerElement = document.querySelector(`#decor-container .sticker[data-sticker=${stickerId}]`);
    const clonedStickerElement = stickerElement.cloneNode(); // 스티커 엘리먼트 복사
    // 복사한 스티커 엘리먼트에 계산한 좌표 적용
    clonedStickerElement.style.left = relativeX;
    clonedStickerElement.style.top = relativeY;
    // #letter-decor-container에 집어넣기!
    document.querySelector("#letter-container #letter-decor-container").appendChild(clonedStickerElement);
});

DataTransfer 인터페이스와 setData(), getData() 함수에 대해서는 MDN 문서를 확인해보세요! DataTransfer(DragEvent.dataTransfer)는 드래그 앤 드롭 이벤트 간에 필요한 데이터를 전달하기 위한 오브젝트입니다.
여기에서 DataTransfer는 drop 이벤트에서 드래그하고 있는 스티커의 ID를 알아내기 위해 사용되었어요.

상대 좌표를 계산하기 위해 위에서 설명했듯 pageX/Y 프로퍼티를 사용하였고, 스티커는 편지지 컨테이너에 상대적인 절대 위치(..?)를 가지도록 CSS에서 설정했기 때문에 편지지 컨테이너의 좌표를 빼주었습니다. 그럼 편지지 컨테이너 내에서의 좌표값만 남겠죠.

이대로 한 번 돌려볼까요? 돌려돌려~🛞

1차 구현 후 드래그 앤 드롭하는 모습

뭔가... 되기는 됐는데 어색한게 느껴지시나요? 스티커를 배치할 때 대충 위치는 맞아도 모종의 위화감이 느껴질텐데, GIF에서는 보이진 않지만 드래그할 때 반투명해지는 스티커의 위치와 드롭해서 배치된 스티커의 위치가 서로 맞지 않는다는 것을 확인하실 수 있을 것 같아요!

추측하건대 드롭할 때 마우스 포인터가 배치된 스티커의 좌표 기준에 위치하는 것 같네요. 배치 위치를 조정해야될 것 같습니다.

그러면 드래그를 시작할 때 스티커 엘리먼트 내에서의 마우스 포인터 위치를 구한 다음, 드롭할 때 위치 계산에서 이 포인터 위치를 빼버리면 어떨까요?

너무 원포인트인가요? 어쩔 수 없어요, 글 쓰기 시작하고 시간이 좀 지나서 감🍊이 다 떨어져버렸거든요...

그럼 코드를 수정해봅시다! 자바스크립트 부분만 수정하면 될 것 같네요.

/* "#decor-container .sticker" forEach문 블럭 */
element.addEventListener("dragstart", (event) => {
    // 스티커 ID
    const id = event.target.dataset["sticker"];
    // 스티커를 드래그할 때 마우스 포인터 오프셋(위치)
    const posX = event.offsetX;
    const posY = event.offsetY;
    
    // DataTransfer.setData()에서 데이터를 수정하여 마우스 포인터 오프셋까지 같이 저장하도록 합니다.
    // 각 데이터(스티커 ID, 포인터 X축 오프셋, 포인터 Y축 오프셋)를 콤마로 분리(comma-separated)하여 쉽게 파싱할 수 있도록 합니다.
    event.dataTransfer.setData("text/plain", `${event.target.dataset["sticker"]},${posX},${posY}`);
});


/* "#letter-container" drop 이벤트 핸들러 블럭 */
...
// 편지지(드롭존) 컨테이너
const letterContainer = document.querySelector("#letter-container");
// 드래그 시 마우스 포인터 좌표, getData()를 하여 얻은 데이터를 콤마를 기준으로 분리합니다.
const [id, posX, posY] = event.dataTransfer.getData("text/plain").split(",");
// X축/Y축 상대 좌표 계산, getData()에서 얻은 마우스 포인터 위치는 string 형식이므로 parseInt()를 하여 number 형식으로 변환 후 계산합니다.
const relativeX = event.pageX - letterContainer.offsetLeft - parseInt(posX);
const relativeY = event.pageY - letterContainer.offsetTop - parseInt(posY);
...

1차로 구현한 코드에서 바뀐 점은 dragstart 이벤트에서 DataTransfer.setData() 할 때 마우스 포인터의 오프셋(위치)가 포함되고, drop 이벤트에서 DataTransfer.getData()로 오프셋을 가져와 상대 좌표(relativeX/Y) 계산에 적용한 정도가 되겠네요!

더 말이 필요하나요? 이렇게 수정하고 다시 돌려봅시다! 돌려돌려돌려~🛞🛞

2차 구현 후 드래그 앤 드롭하는 모습

이제 위화감이 사라졌을 것 같네요! 사용자가 의도하는 위치에 정확히 스티커가 배치되는 모습을 보니 마음속 응어리가 쑥 내려가는 것만 같습니다! 이 이후로 다양한 구현(스티커 위치 이동, 스티커 삭제 등등...)은 여러분에게 달려있습니다!


글 쓰다말고 SW마에스트로 마무리에 집중하게 되어서(변명) 글의 흐름이 깨지고 코드로 대충 때운 점 이해 부탁드리며... 혹시나 글에 부족한 부분이나 잘못된 코드, 또는 궁금한 점이 있다면 댓글로 남겨주시거나 제 트위터(@somni_somni)로 멘션 또는 DM 남겨주시면 아는 한 답변해 드릴게요!

그럼 이만!

이야! 드디어 글 완성했다!