본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.
브라우저에서 마우스 이벤트는 가장 많이 사용되는 이벤트 중 하나라고 해도 과언이 아니다. 유저와의 상호작용 중에는 클릭 등과 같이 마우스를 통해 발생하는 이벤트가 매우 많고 다양하기 때문이다. 마우스 이벤트에서 주의해야 할 점은, 단순히 PC환경에서 마우스 장치를 통해 발생하는 것이 아니라 핸드폰과 같은 모바일 디바이스에서도 터치 등과 같은 동작에 동일하게 발생한다는 점이다.
이전 챕터에서 이벤트를 설명하며 이미 살펴본 마우스 이벤트가 여럿 있다. 이를 포함한 다른 이벤트 역시 간단하게 살표보자.
mousedown/mouseup
특정 요소 위에서 마우스 왼쪽 버튼을 누를 때, 또는 마우스 버튼을 누른 상태에서 뗄 때 발생한다.
mouseover/mouseout
마우스 커서가 특정 요소 외부에 있다가 내부로 들어올 때, 또는 내부에 있다가 요소 외부로 나갈 때 발생한다.
mousemove
마우스를 움직일 때 발생한다.
click
마우스 왼쪽 버튼을 사용해 동일한 요소 위에서 mousedown
과 mouseup
이벤트를 연달아 발생시킬 때 실행된다.
dblclick
동일한 요소 위에서 마우스 왼쪽 버튼을 빠르게 연달아 클릭하는 경우 발생한다. 요즘에는 잘 쓰이지 않는다.
contextmenu
마우스 오른쪽 버튼을 눌렀을 때 발생한다. OS 또는 브라우저에 따라 특별한 단축키로 동일환 컨텍스트 메뉴가 나타나게 할 수 있지만 이는 contextmenu
라는 마우스 이벤트와 동일하지는 않다. 나타나는 메뉴 역시 OS 또는 브라우저마다 조금씩 상이하다.
이 외에도 여러 마우스 이벤트가 있는데, 이는 다음 챕터에서 살펴보도록 하자.
위에서 살펴본 몇몇 마우스 이벤트의 경우엔, 단 하나의 동작을 하더라도 실행되는 이벤트가 여러 개일 수 있다. 대표적으로 click
이벤트는 사용자가 마우스 왼쪽 버튼을 클릭했을 때 발생하는 이벤트로 정의할 수 있지만, 내부적으로는 mousedown
과 mouseup
이벤트가 순차적으로 발생하는 것과 같다.
이와 같이 동작은 하나이지만 여러 이벤트가 실행될 때 실행 순서는 내부 규칙에 따라 결정된다.
클릭과 연관된 이벤트는 정확히 어떤 버튼에서 이벤트가 발생했는지 알려주는 프로퍼티 button
을 가지고 있다. 여기서 버튼은 보통 마우스 버튼을 의미한다.
click
이벤트는 마우스 왼쪽 버튼을, contextmenu
이벤트는 마우스 오른쪽 버튼을 눌렀을 때 발생하는 이벤트이다. 때문에 보통 두 이벤트를 다룰 땐 button
프로퍼티를 잘 사용하지 않는다.
반면 mousedown
과 mouseup
이벤트의 경우는 해당 이벤트의 핸들러에 event.button
프로퍼티에 따라 동작을 명시해야 할 필요가 있을 수 있다. 이 이벤트들은 버튼을 구분하지 않고 어디에서나 발생할 수 있는 이벤트이기 때문이다. button
프로퍼티의 값을 정리하면 다음과 같다.
버튼 | event.button |
---|---|
왼쪽 | 0 |
가운데 | 1 |
오른쪽 | 2 |
뒤로가기 | 3 |
앞으로가기 | 4 |
상당 수의 마우스는 왼쪽, 오른쪽, 가운데(휠) 버튼만 가지고 있는 경우가 많다. 만약 마우스가 뒤로가기 또는 앞으로가기 버튼을 지원하는 경우에도 event.button
프로퍼티로 그 값을 읽을 수 있다. 또한 터치를 지원하는 기기들도 사람이 해당 기기를 터치했을 때 이와 유사한 이벤트를 생성한다.
이전에는 event.which
라는 프로퍼티를 사용해서 어떤 버튼을 클릭했는지 파악할 수 있었다. 하지만 이는 비표준 프로퍼티이고 event.button
이 등장한 이후로는 지원하지 않는 프로퍼티이기 때문에 더 이상 사용되지 않는다.
마우스 이벤트는 이벤트가 발생할 때 함께 누른 보조키가 무엇인지까지 알려주는 프로퍼티도 지원한다. 보조키 별로 지원하는 프로퍼티는 다음과 같다.
shiftKey
: Shift
키altKey
: Alt
키 ( MAC
에선 Opt
키 )ctrlKey
: Ctrl
키metaKey
: MAC
에서 Cmd
키마우스 이벤트가 발생하는 도중에 해당 키가 함께 눌러진 경우엔 해당 프로퍼티 값은 true
가 된다. 이를 통해 보조키가 눌렸는지 아닌지를 체크할 수 있다.
button.onclick = function (event) {
if (event.altKey && event.shiftKey) {
alert('hello world!');
}
}
위 코드에서 버튼은 Alt + Shift
키와 마우스 왼쪽 버튼을 함께 클릭했을 때만 alert
창이 출력될 것이다.
MAC
운영체제에서는 Cmd
라는 보조키를 추가로 지원하는데, 이는 meteKey
로 접근할 수 있다. 응용 프로그램 대부분 윈도우 또는 리눅스에서는 Ctrl
키를, MAC
에서는 Cmd
키를 사용해 단축키를 조합하는 경우가 많다.
이런 암묵적인 규칙으로 인해 윈도우 사용자가 Ctrl + A
와 같은 조합키를 사용할 때 발생하는 동작이 MAC
에서 동일하게 Cmd + A
를 눌렀을 때 동작하는 경우가 많다.
따라서 MAC
사용자 역시 고려한 브라우저 환경을 만들기 위해서는 Ctrl
키와 click
이벤트가 함께 발생했을 때 나타나는 효과가 Cmd + click
에도 동일하게 적용되게끔 해야한다. 이는 다음의 코드로 구현할 수 있다.
if (event.ctrlKey || event.metaKey) { ... }
마우스 이벤트는 두 종류의 좌표 정보를 지원한다. 좌표 정보에 대한 설명은 이전 문서 Document (4) 포스트에서 언급한 바 있다.
clientX
, clientY
pageX
, pageY
두 좌표 체계를 다시 한번 간단히 정리하면 다음과 같다. 클라이언트 좌표의 경우엔 브라우저 창을 기준으로 왼쪽과 상단으로부터 얼마나 떨어져 있는지를 나타내기 때문에 스크롤이 발생하면 해당 값도 변한다. 반면 페이지 좌표의 경우는 웹 문서를 기준으로 각각 왼쪽과 상단으로부터 얼마나 떨어져 있는지를 나타내며 만약 스크롤이 되더라도 이 값은 변하지 않는다.
글자 위에서 마우스를 더블클릭 하는 경우 음영과 함께 글자 부분이 선택되는 브라우저 기본 동작이 있다 (음영의 색상 및 스타일은 브라우저 별로 상이할 수 있다). 그러나 간혹 이러한 기본 동작이 사용자 경험을 해친다고 판단되는 경우 이를 막아야 할 필요가 생길 수 있다.
dblclick
이벤트가 발생했을 때 alert
창을 띄우고자 하는 경우, 다음과 같이 코드를 작성한다면 핸들러가 실행되는 동시에 기본 동작도 수행되어 선택된 텍스트에 음영처리가 될 것이다.
<span ondblclick="alert('dblclick')">
이 곳을 클릭해보세요!
</span>
이 외에도 마우스 왼쪽 버튼을 누른 채 커서를 이리저리 움직여도 글자가 선택되는 부수효과(side-effect
) 역시 사용자 경험을 해칠 수 있다. 이러한 동작은 모두 브라우저 기본 동작이기 때문에 앞서 살펴본 것과 같이 이벤트 핸들러에서 return false
를 해줌으로써 방지할 수 있다.
<span ondblclick="alert('dblclick')" onmousedown="return false">
이 곳을 클릭해보세요!
</span>
브라우저 기본동작을 막았기 때문에 더블 클릭을 하게 되더라도 이전처럼 더 이상 음영부분이 생기지 않는 것을 확인할 수 있다. 그러나 드래그를 통해 글자를 선택할 수 있는 동작은 이러한 방법으로도 여전히 막을 수가 없다.
만약 콘텐츠를 보호하려는 목적으로 위와 같은 기본 동작을 막으려고 한다면, oncopy
라는 이벤트를 활용할 수 있다.
<div oncopy="alert('불법 복제 예방!'); return false">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent convallis ultrices lacus ut dictum. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
</div>
해당 글을 선택한 후 복사를 하려고 하는 경우엔 oncopy
이벤트의 기본 기능이 막혀있기 때문에 복사가 불가한 것을 확인할 수 있다. 물론 페이지 소스 보기를 통해 원문의 내용에 여전히 접근 가능하지만 일반적인 경우에는 이 같은 방식으로 복사를 방지할 수 있다.
위에서 언급한 마우스 이벤트는 주로 클릭과 관련된 이벤트였다. 이번에는 클릭 이외에 마우스를 움직일 때 발생하는 마우스 이벤트에 대해 살펴보자.
mouseover
이벤트는 마우스 커서가 어떤 요소에 진입할 때 발생하고, mouseout
은 반대로 커서가 어떤 요소에서 나갈 때 발생하는 이벤트이다. 이를 그림으로 나타내면 다음과 같다.
이동과 관련된 마우스 이벤트는 앞서 살펴본 클릭 이벤트와 두드러지는 차이점을 하나 가진다. 바로 relatedTarget
이라는 프로퍼티를 추가로 가진다는 점이다. 이는 이벤트 객체가 기본적으로 가지고 있는 target
프로퍼티를 보완한다. 마우스 이동이 발생하는 지점은 항상 외부 <-> 내부
의 흐름이 발생한다. 이때 어떤 이벤트냐에 따라 target
은 외부 요소일 수도, 내부 요소일 수도 있다. target
요소가 정해짐에 따라 relatedTarget
역시 나머지 한 요소를 가리키게 된다.
mouseover
의 경우event.target
: 현재 마우스 커서가 진입한 요소event.relatedTarget
: 현재 마우스 커서가 떠나는 요소 (relatedTarget → target
)mouseout
의 경우event.target
: 현재 마우스 커서가 떠나는 요소event.relatedTarget
: 현재 마우스 커서가 진입한 요소 (target → relatedTarget
)마우스 이동 이벤트가 무엇이냐에 따라 그 정의가 서로 상반되는 것을 볼 수 있다. 정의는 상반되지만 그렇기 때문에 두 이벤트에서 각각의 프로퍼티가 가리키는 값은 동일하다.
이때 relatedTarget
은 null
값이 될 수도 있다. 만약 브라우저 요소에서가 아니라 브라우저 창 밖을 넘어가거나 HTML 요소로부터 접근이 된 것이 아닌 경우엔 null
값을 가지게 된다. 따라서 이 경우를 고려해 event.relatedTarget.tagName
과 같이 접근할 때 NullPointerException
등의 에러가 발생하지 않도록 주의해야 한다.
이동과 관련된 마우스 이벤트인 mouseover/mouseout
은 마우스 이동이 있을 때마다 발생한다. 그러나 모든 마우스 움직임에 대하여 1:1로 대응하지는 않는다.
브라우저는 마우스의 현재 위치를 주기적으로 체크를 하는데, 만약 해당 주기보다 더 빠르게 요소와 요소사이를 마우스로 이동하는 경우에는 지나친 요소들은 마우스 이동 이벤트를 잡아내지 못한다. 즉 매우 빠르게 움직이는 이동은 정상적으로 감지할 수 없다.
위 그림에서 #FROM
요소에서 #TO
요소로 이동할 때 매우 빠르게 이동한다면 그 사이에 위치한 DIV
요소들에서는 이 마우스 이동 이벤트를 잡아낼 수 없다. 이는 성능적인 측면에서 고려된 설계인데, 만약 마우스가 지나치는 모든 요소들이 이를 잡아낸다면 브라우저에 많은 부하가 걸리게 되기 때문이다.
이와 같은 특성 때문에 앞에서 언급했던 것과 같이 relatedTarget
은 null
값이 될 수 있다. 브라우저 창 외부에서 매우 빠르게 특정 요소로 마우스를 진입한다면 그 사이사이에 위치한 요소들은 정상적으로 이를 인지할 수 없기 때문이다.
만약 mouseover
이벤트가 발생했다면 항상 mouseout
이벤트가 따라서 발생하는 것이 보장되어야 한다. 아무리 빨리 마우스 이동을 한다 하더라도 진입과 이탈은 짝으로 발생하기 때문이다.
mouseout
이벤트에서 부모-자식 관계의 요소가 발생하는 경우를 생각해보자. 다음과 같은 HTML 문서가 있다고 가정하자.
<div id="parent">
<div id="child">...</div>
</div>
현재 마우스 커서가 #parent
요소에 위치하고 있고, 그리고 커서를 #child
요소로 옮기는 경우 mouseout
이벤트는 #parent
요소에서 발생할 것이다.
하지만 #child
요소는 중첩된 요소일 뿐 여전히 #parent
요소안에 존재하고 있는 자식 요소이다. 그렇지만 mouseout
이벤트는 #parent
를 떠났다고 인식하고 있다.
이러한 동작 방식에서 알 수 있는 중요한 점은, 브라우저 내부 로직에 의하여 마우스 커서는 항상 단일 요소 위에서 존재해야 한다는 점이다. 이 단일 요소는 가장 중첩된 구조 안 쪽에 존재하거나 z-index
속성이 제일 큰 경우를 가리킨다. 위 경우에서는 #child
요소가 #parent
내부에 있지만 해당 영역이 부모를 덮어쓰기 때문에 단일 요소로 판단되는 것이다.
또 재미난 점은 mouseover/out
이벤트는 버블링이 일어난다는 점이다. 부모 요소인 #parent
에만 onmouseover/out
핸들러를 등록해도 자식 요소인 #child
에서 발생하는 마우스 이동 이벤트를 잡아낼 수 있다.
<div id='parent' onmouseover='mouselog(event)' onmouseout='mouseout(event)'>
<div id='child'>child</div>
</div>
<script>
function mouselog (event) {
let d = new Date();
text.value += `${d.getHours()}:${d.getMinutes()}:${d.getSeconds()} | ${event.type} [target: ${event.target.id}]\n`.replace(/(:|^)(\d\D)/, '$10$2');
text.scrollTop = text.scrollHeight;
}
</script>
위 코드에서 만약 마우스를 #parent
에서 #child
로 옮긴다면 #parent
요소에서 mouseover
과 mouseout
이벤트 두 개 모두 잡아내는 것을 확인할 수 있다. 이때 mouseover
는 #child
에서 발생한 것이고, mouseout
은 #parent
에서 발생한 것이다.
mouseout [target: parent]
이 먼저 발생하고mouseover [target: child]
이 발생따라서 mouseover/out
이벤트는 버블링이 디폴트로 일어나는 것을 확인할 수 있다. 때문에 만약 핸들러 내부에서 event.target
을 구분하지 않는다면 특정 요소를 떠날때 곧바로 진입 이벤트가 발생하거나 그 반대의 경우 역시 발생할 수 있다.
그러나 애니메이션과 같은 동작이 오직 parent.onmouseout
을 통해 완전히 외부로 벗어나는 경우에만 발생하고자 하는 경우에는 이 같은 버블링은 이를 방해할 수 있다. 왜냐하면 parent
내부에 다른 요소가 있는 경우, 해당 요소로 진입하는 경우에도 parent
를 떠나는 것으로 잡아내기 때문이다. 따라서 이를 방지하기 위해서는 relatedTarget
프로퍼티를 이용하여 FROM
과 TO
를 세밀하게 정의하여 조건을 추가해야 할 것이다.
그렇지만 일일이 이를 조정하는 것 대신 mouseenter/mouseleave
이벤트를 사용하는 것도 좋은 대안이 될 수 있다.
mouseenter/leave
이벤트는 그 의미에서 알 수 있듯이 어떤 요소에 진입 또는 이탈할 때 발생하는 이벤트다. 즉 발생 시점과 조건은 위에서 살펴본 mouseover/out
이벤트와 동일하다. 그러나 가장 큰 차이점은 해당 이벤트는 버블링이 되지 않는다는 점이다. 따라서 이벤트 위임 패턴을 사용할 수 없다. 또 하나의 차이점은 부모-자식 요소 사이에 전환은 취급하지 않는다는 점이다. 예를 들어 부모 내부에 있는 자식은 앞서 살펴본 것과 달리 새로운 단일 요소로 인식하지 않는다. 즉 부모 요소에 해당 이벤트 핸들러를 등록했다면, 부모 자체에서만 이벤트가 발생하는 것을 감지한다.
따라서 앞서 언급했던 문제는 mouseenter/leave
이벤트로 대체하는 것으로 손쉽게 해결할 수 있다. 이벤트 버블링이 일어나지 않고 등록된 요소 자체에서만 이벤트를 감지하기 때문에, parent.onmouseleave
를 정의한다면 해당 요소 자체를 떠날 때만 특정 동작이 발생함을 보장할 수 있다.
mouseenter/leave
이벤트는 매우 간단하고 사용하기 편하다. 그렇지만 치명적인 단점을 가지고 있는데, 바로 이벤트 버블링이 안 되기 때문에 이벤트 위임 패턴을 사용할 수 없다는 점이다.
만약 요소가 한 두개 정도라면 이는 큰 단점이 아니다. 그렇지만 <table>
구조를 생각해보자. 만약 테이블 내부에 수백개의 셀이 존재한다면, 해당 셀에 일일이 이벤트를 달아주는 것은 엄청난 노동을 요구한다. 이런 경우에는 이전 챕터에서 살펴본 것과 같이 부모 요소에 이벤트 핸들러를 달아주고 컨트롤 하는 이벤트 위임 패턴을 사용하는 것이 훨씬 효과적이다.
때문에 이벤트 위임을 활용하기 위해서는 다시 mouseover/out
이벤트로 돌아와야 한다. 해당 이벤트를 사용해서 이벤트 위임 방식으로 각 테이블 셀에 진입했을때 배경색을 바꿀 수 있는 핸들러를 등록해보자.
table.onmouseover = function (event) {
let target = event.target;
target.style.background = 'pink';
}
table.onmouseout = function (event) {
let target = event.target;
target.style.background = '';
}
<table>
은 모든 셀(<td>
)의 공통 조상 요소이기 때문에 셀에서 발생하는 마우스 이동을 모두 감지할 수 있다. 그러나 이 같은 방식은 앞서 이벤트 위임을 구현할 때 가장 먼저 마주한 동일한 문제를 가지고 있다. 만약 <td>
내부에 또 다른 자식 요소가 있을 때는 <td>
전체의 배경색이 아닌 해당 자식 요소의 배경색만 변경된다는 점이다. 이는 이벤트가 모든 요소에서 버블링되기 때문인데, 앞서 이 문제를 해결하기 위해서는 우리는 현재 위치한 셀이 <td>
와 맞는지 따로 검사해주었다. 따라서 여기서도 동일하게 이를 적용해주어야 한다.
<td>
를 기억하도록 해야한다. 이를 currentElem
이라는 변수에 저장하도록 하자.mouseover
이벤트가 발생할 때 만약 현재 커서가 currentElem
내부에 있다면 이를 무시하도록 하자.mouseout
이벤트가 발생할 때 만약 currentElem
을 떠나는 것이 아니면 무시하도록 하자.let currentElem = null;
table.onmouseover = function (event) {
// currentElem이 존재한다는 것은 현재 커서가
// <td> 내부에 있다는 것을 의미한다.
// 따라서 이 경우 해당 이벤트는 무시한다.
if (currentElem) return;
let target = event.target.closest('td');
// 가장 가까운 조상 요소가 <td>가 아닌 경우도 무시
if (!target) return;
// 중첩여부를 고려해 현재 테이블에 속한 <td>인지 검사
if (!table.contains(target)) return;
currentElem = target;
onEnter(currentElem);
}
table.onmouseout = function (event) {
// currnetElem이 없다는 것은 현재 커서가
// <td> 외부에 있다는 것을 의미한다.
// 따라서 이 경우 해당 이벤트는 무시한다.
if (!currentElem) return;
// <td>요소를 떠나 어디로 향하는지 체크해야 한다.
// <td>의 자식으로 가는 것도 mouseout에 감지되기 때문
let relatedTarget = event.relatedTarget;
// 반복문을 돌며 relatedTarget이 결국
// 현재 <td> 내부에 있는지를 체크한다.
// 만약 최종 공통 조상이 currentElem이 된다면
// 이는 <td> 내부에서 발생한 mouseout 이벤트므로 무시
while (relatedTarget) {
if (relatedTarget === currentElem) return;
relatedTarget = relatedTarget.parentNode;
}
onLeave(currentElem);
currentElem = null;
}
function onEnter(elem) {
elem.style.background = 'pink';
}
function onLeave(elem) {
elem.style.background = '';
}
앞서 이벤트 위임 패턴을 살펴볼 때 event.target
을 통해 각각 처리를 따로 구현한 것과 유사한 흐름이다. 다만 마우스 이동과 관련된 프로퍼티를 사용해서 정확히 마우스 커서가 어떤 요소에 머무르고 있는지를 잘 파악해서 이벤트 위임을 구현해야 한다.
드래그(drag
)와 드롭(drop
)은 모던 브라우저에서 기본적으로 제공하고 있는 기능이다. 사용자와 컴퓨터 간의 상호작용을 지원하는 기능하는 훌륭한 도구로, 파일 관리 애플리케이션에서 문서를 복사해 이동하는 것부터 주문하려는 물건을 장바구니에 드롭하는 것까지 단순 명쾌하게 원하는 동작을 수행할 수 있다.
모던 HTML 표준에서는 dragstart
, dragend
등의 특수한 이벤트와 함께 드래그 앤 드롭을 구현할 수 있다. 해당 이벤트는 운영체제의 파일 관리 애플리케이션으로부터 파일을 드래그해서 브라우저 화면에 드롭하는 특별한 기능 역시 제공한다. 그 이후 자바스크립트로 가져온 파일의 내용을 다룰 수 있다.
그러나 기본 드래그 이벤트엔 한계가 있다. 예를 들어 특정 영역에서 드래그하는 것을 막을 수 없다. 수평이나 수직으로만 드래그 하는 것 역시 제공되지 않는다. 모바일 환경에서의 지원이 부족하다는 것 역시 한계로 작용한다.
해당 챕터에서는 기본 드래그 이벤트의 한계를 극복하기 위해 마우스 이벤트로만 드래그 앤 드롭을 구현해보도록 하자.
드래그 앤 드롭의 알고리즘은 간단하다. 이를 마우스 이벤트와 연관지어보면 결국 mousedown ➡ mousemove ➡ mouseup
의 순서로 진행된다는 것을 알 수 있다.
mousedown
에서는 움직임이 필요한 요소를 준비한다. 이때 기존 요소의 복사본을 만들거나, 해당 요소에 클래스를 추가하는 등의 작업을 할 수 있다.
이후 mousemove
에서 특정 요소의 position: absolute
와 left, top
등을 조정한다.
mouseup
에서는 드래그 앤 드롭 완료와 관련된 모든 작업을 수행한다.
이를 바탕으로 구현한 드래그 앤 드롭은 다음과 같다.
ball.onmousedown = function (event) {
// ball 요소의 스타일을 조정
// html 문서 제일 위에서 동작하도록 변경
ball.style.position = 'absolute';
ball.styled.zIndex = 1000;
// body를 기준으로 위치를 지정
document.body.append(ball);
// 공의 위치를 pageX, pageY 좌표 중앙에 위치시키는 함수
function moveAt(pageX, pageY) {
ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
ball.style.top = pageY - ball.offsetWidth / 2 + 'px';
}
// 처음 클릭했을 시 현재 커서 위치로 공을 이동
moveAt(event.pageX, event.pageY);
// 이벤트 등록/제거를 위해 함수 선언
function onMouseMove(event) {
moveAt(event.pageX, event.pageY);
}
// document 레벨에서 이벤트 등록: mousemove로 공을 움직임
document.addEventListener('mousemove', onMouseMove);
// 공을 드롭하고, 불필요한 핸들러 제거
ball.onmouseup = function() {
document.removeEventListener('mousemove', onMouseMove);
ball.onmouseup = null;
};
};
마우스로 드래그 앤 드롭을 시도하면 공을 찍어 올리기는 하지만, 무언가 이상하다. 공 자체의 요소가 아니라 복사된 공을 이리저리 끌고 다니며 드래그 하는 것을 볼 수 있다.
이는 브라우저 자체적으로 이미지나 요소에 대한 드래그 앤 드롭을 지원하기 때문이다. 브라우저에서 제공하는 기능이 자동 실행되어 위에서 작성한 코드와 충돌을 일으킨 것이다. 따라서 이 기능을 비활성화 해주면 작성한 코드대로 움직이게 될 것이다.
// 브라우저 드래그 앤 드롭 기본 동작 제거
ball.ondragstart = function () {
return false;
}
위 코드에서 또 하나 중요한 점은 ball
요소 레벨이 아닌 document
레벨에서 mousemove
이벤트를 관리하고 있는 것이다. 처음 볼 때 마우스가 항상 공 위에 있으며, 이곳에 mousemove
를 넣을 수 있다. 그러나 마우스 이동은 앞서 설명한 바와 같이 빠르게 움직이는 경우에는 요소들을 건너뛸 수 있다. 그렇게 되면 공의 위치가 document
중간이나 윈도우 어딘가로 점프되는 현상이 발생할 수 있다. 이러한 현상을 방지하기 위해서 document
레벨에서 해당 이벤트를 관리하는 것이다.
위 예제에서 공의 위치를 설정하는 코드는 아래와 같다.
ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
동작하는데는 문제가 없지만 부자연스러운 흐름이 있다. 드래그 앤 드롭을 시작하기 위해 공 위 어디에서든 mousedown
을 할 수 있는데, 만약 공의 가장자리에 가깝게 mousedown
을 하게 되면, 마우스 포인터 아래로 공이 갑작스럽게 이동하게 된다. 이는 사용자 경험에 부정적인 영향을 끼칠 수 있기 때문에, 포인터를 기준으로 요소의 초기 이동을 유지하는 방법이 더 좋다. 예를 들어 공의 가장자리에서 드래그 하기 시작했다면, 공을 드래그 하는 동안엔 포인터가 공의 가장자리에 유지돼야 한다.
이를 위해 공의 위치를 자연스롭게 조절하는 코드를 추가로 구현하자. 그림에서 보는 것과 같이 shiftX, shiftY
변수를 추가로 두어 관리할 수 있다.
shiftX, shiftY
변수를 통해 공의 왼쪽 위 모서리까지의 거리를 기억하게 하고, 드래그하는 동안 이 거리를 유지하게 하자. 거리를 유지하는 움직임은 포인터의 좌표에서 공의 왼쪽 위 좌표를 빼서 구할 수 있다.let shiftX = event.clientX - ball.getBoundingClientRect().left;
let shiftY = event.clientY - ball.getBoundingClientRect().top;
ball.style.left = event.pageX - shiftX + 'px';
ball.style.top = event.pageY - shiftY + 'px';
위 두 개선된 코드를 앞서 구현한 코드에 추가하면 자연스러운 무브먼트를 보이는 공의 드래그 앤 드롭을 구현할 수 있다.
지금까지 본 예제에서는 공을 어디에서나 드롭할 수 있었다. 그러나 파일을 폴더나 다른 곳에 드롭하듯 실생활에서는 보통 한 요소를 다른 요소에 드롭하는 경우가 많다. 즉 드래그 가능한 요소를 드롭 가능한 요소에 두는 행위가 보장된다. 이를 구현하기 위해 다음 두 가지를 살펴보자.
잠재적으로 놓을 수 있는 요소를 강조하기 위해서 간단히 생각할 수 있는 방법으로는 해당 요소에 mouseover, mouseup
핸들러를 설정하는 것이다. 그렇지만 이 방법은 정상적으로 동작하지 않는다.
드래그 하는 동안 드래그 할 수 있는 요소가 항상 다른 요소 위에 있다는 것이 문제가 된다. 마우스 이벤트의 맨 위 요소에서만 이벤트가 발생하기에, 맨 위 요소 아래에 위치한 다른 요소에서는 이벤트가 무시된다. 예를 들어 아래의 코드에서는 빨간색 요소가 항상 위에 있기 때문에, 파란색 요소에서 발생하는 이벤트는 잡을 수가 없다.
<style>
div {
width: 50px;
height: 50px;
position: absolute;
top: 0;
}
</style>
<div style="background: blue" onmouseover="alert('never works')"></div>
<div style="background: red" onmouseover="alert('works')"></div>
드래그 할 수 있는 요소도 빨간색 요소가 파란색 요소를 덮은 경우와 동일한 상황으로 볼 수 있다. 공은 항상 다른 요소 위에 있어 이벤트가 발생하지만, 공 하위 요소에 설정한 어떠한 핸들러도 동작하지 않을 것이다.
때문에 우리는 앞서 좌표 챕터에서 살펴본 document.elementFromPoint(clientX, clientY)
메서드를 활용해 이 문제를 해결할 것이다. 해당 메서드는 주어진 윈도우 기준 좌표에서 가장 많이 중첩된 요소를 반환한다. 따라서 다음과 같이 마우스 이벤트 핸들러에서 포인터 아래에 드롭 가능성을 감지할 수 있다.
// 현재 드래그하고 있는 요소를 잠시 숨김
ball.hidden = true;
// 드롭 할 수 있는 공의 아래 요소
let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
// 다시 공을 보이게 처리
ball.hidden = false;
이전에 공을 숨김 처리하는 이유는 현재 공의 아래 요소인 elemBelow
를 찾기 위해서이다. 만약 숨김 처리를 하지 않는다면 공은 보통 포인터 아래의 맨 위 요소이기 때문에 elemBelow = ball
이 될 것이다. 이를 통해 드롭 가능한 요소를 찾기 위한 코드를 구현해보자.
// 즉시 날아가는 잠재적 드롭 가능한 요소
let currentDroppable = null;
function onMouseMove (event) {
moveAt(event.pageX, event.pageY);
ball.hidden = true;
let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
ball.hidden = false;
// 마우스 이벤트가 윈도우 밖으로 드래그하는 것 방지
// elementFromPoint는 clientX/Y가 윈도우 밖에 있다면 null 반환
if (!elemBelow) return;
// 잠재적으로 드롭 가능한 요소의 클래스를 droppable로 정의
let droppableBelow = elemBelow.closest('.droppable');
if (currentDroppable !== droppableBelow) {
// 두 값 모두 null 일 수 있다.
// currentDroppable=null : 이벤트 전에 놓을 수 있는 요소 위에 있지 않은 경우
// droppableBelow=null : 이벤트 동안 놓을 수 있는 요소 위에 있지 않은 경우
if (currentDroppable) {
// 날아가는 것을 처리하는 로직 (배경색 제거)
leaveDroppable(currentDroppable);
}
currentDroppable = droppableBelow;
if (currentDroppable) {
// 들어오는 것을 처리하는 로직 (배경색 지정)
enterDroppable(currentDroppable);
}
}
}