[모던JS: 브라우저] UI 이벤트 (1)

KG·2021년 6월 17일
1

모던JS

목록 보기
33/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

마우스 이벤트

브라우저에서 마우스 이벤트는 가장 많이 사용되는 이벤트 중 하나라고 해도 과언이 아니다. 유저와의 상호작용 중에는 클릭 등과 같이 마우스를 통해 발생하는 이벤트가 매우 많고 다양하기 때문이다. 마우스 이벤트에서 주의해야 할 점은, 단순히 PC환경에서 마우스 장치를 통해 발생하는 것이 아니라 핸드폰과 같은 모바일 디바이스에서도 터치 등과 같은 동작에 동일하게 발생한다는 점이다.

1) 마우스 이벤트 종류

이전 챕터에서 이벤트를 설명하며 이미 살펴본 마우스 이벤트가 여럿 있다. 이를 포함한 다른 이벤트 역시 간단하게 살표보자.

mousedown/mouseup

특정 요소 위에서 마우스 왼쪽 버튼을 누를 때, 또는 마우스 버튼을 누른 상태에서 뗄 때 발생한다.

mouseover/mouseout

마우스 커서가 특정 요소 외부에 있다가 내부로 들어올 때, 또는 내부에 있다가 요소 외부로 나갈 때 발생한다.

mousemove

마우스를 움직일 때 발생한다.

click

마우스 왼쪽 버튼을 사용해 동일한 요소 위에서 mousedownmouseup 이벤트를 연달아 발생시킬 때 실행된다.

dblclick

동일한 요소 위에서 마우스 왼쪽 버튼을 빠르게 연달아 클릭하는 경우 발생한다. 요즘에는 잘 쓰이지 않는다.

contextmenu

마우스 오른쪽 버튼을 눌렀을 때 발생한다. OS 또는 브라우저에 따라 특별한 단축키로 동일환 컨텍스트 메뉴가 나타나게 할 수 있지만 이는 contextmenu라는 마우스 이벤트와 동일하지는 않다. 나타나는 메뉴 역시 OS 또는 브라우저마다 조금씩 상이하다.

이 외에도 여러 마우스 이벤트가 있는데, 이는 다음 챕터에서 살펴보도록 하자.

2) 마우스 이벤트 순서

위에서 살펴본 몇몇 마우스 이벤트의 경우엔, 단 하나의 동작을 하더라도 실행되는 이벤트가 여러 개일 수 있다. 대표적으로 click 이벤트는 사용자가 마우스 왼쪽 버튼을 클릭했을 때 발생하는 이벤트로 정의할 수 있지만, 내부적으로는 mousedownmouseup 이벤트가 순차적으로 발생하는 것과 같다.

이와 같이 동작은 하나이지만 여러 이벤트가 실행될 때 실행 순서는 내부 규칙에 따라 결정된다.

3) 마우스 버튼

클릭과 연관된 이벤트는 정확히 어떤 버튼에서 이벤트가 발생했는지 알려주는 프로퍼티 button을 가지고 있다. 여기서 버튼은 보통 마우스 버튼을 의미한다.

click 이벤트는 마우스 왼쪽 버튼을, contextmenu 이벤트는 마우스 오른쪽 버튼을 눌렀을 때 발생하는 이벤트이다. 때문에 보통 두 이벤트를 다룰 땐 button 프로퍼티를 잘 사용하지 않는다.

반면 mousedownmouseup 이벤트의 경우는 해당 이벤트의 핸들러에 event.button 프로퍼티에 따라 동작을 명시해야 할 필요가 있을 수 있다. 이 이벤트들은 버튼을 구분하지 않고 어디에서나 발생할 수 있는 이벤트이기 때문이다. button 프로퍼티의 값을 정리하면 다음과 같다.

버튼event.button
왼쪽0
가운데1
오른쪽2
뒤로가기3
앞으로가기4

상당 수의 마우스는 왼쪽, 오른쪽, 가운데(휠) 버튼만 가지고 있는 경우가 많다. 만약 마우스가 뒤로가기 또는 앞으로가기 버튼을 지원하는 경우에도 event.button 프로퍼티로 그 값을 읽을 수 있다. 또한 터치를 지원하는 기기들도 사람이 해당 기기를 터치했을 때 이와 유사한 이벤트를 생성한다.

이전에는 event.which 라는 프로퍼티를 사용해서 어떤 버튼을 클릭했는지 파악할 수 있었다. 하지만 이는 비표준 프로퍼티이고 event.button이 등장한 이후로는 지원하지 않는 프로퍼티이기 때문에 더 이상 사용되지 않는다.

4) shift, alt, ctrl, meta 프로퍼티

마우스 이벤트는 이벤트가 발생할 때 함께 누른 보조키가 무엇인지까지 알려주는 프로퍼티도 지원한다. 보조키 별로 지원하는 프로퍼티는 다음과 같다.

  • 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) { ... }

5) clientX, clientY와 pageX, pageY

마우스 이벤트는 두 종류의 좌표 정보를 지원한다. 좌표 정보에 대한 설명은 이전 문서 Document (4) 포스트에서 언급한 바 있다.

  • 클라이언트 좌표 : clientX, clientY
  • 페이지 좌표 : pageX, pageY

두 좌표 체계를 다시 한번 간단히 정리하면 다음과 같다. 클라이언트 좌표의 경우엔 브라우저 창을 기준으로 왼쪽과 상단으로부터 얼마나 떨어져 있는지를 나타내기 때문에 스크롤이 발생하면 해당 값도 변한다. 반면 페이지 좌표의 경우는 웹 문서를 기준으로 각각 왼쪽과 상단으로부터 얼마나 떨어져 있는지를 나타내며 만약 스크롤이 되더라도 이 값은 변하지 않는다.

6) mousedown 이벤트와 선택 막기

글자 위에서 마우스를 더블클릭 하는 경우 음영과 함께 글자 부분이 선택되는 브라우저 기본 동작이 있다 (음영의 색상 및 스타일은 브라우저 별로 상이할 수 있다). 그러나 간혹 이러한 기본 동작이 사용자 경험을 해친다고 판단되는 경우 이를 막아야 할 필요가 생길 수 있다.

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 이벤트의 기본 기능이 막혀있기 때문에 복사가 불가한 것을 확인할 수 있다. 물론 페이지 소스 보기를 통해 원문의 내용에 여전히 접근 가능하지만 일반적인 경우에는 이 같은 방식으로 복사를 방지할 수 있다.

마우스 무빙(Moving)관련 이벤트

위에서 언급한 마우스 이벤트는 주로 클릭과 관련된 이벤트였다. 이번에는 클릭 이외에 마우스를 움직일 때 발생하는 마우스 이벤트에 대해 살펴보자.

1) mouseover/mouseout과 relatedTarget

mouseover 이벤트는 마우스 커서가 어떤 요소에 진입할 때 발생하고, mouseout은 반대로 커서가 어떤 요소에서 나갈 때 발생하는 이벤트이다. 이를 그림으로 나타내면 다음과 같다.

mouseover/mouseout 이벤트 흐름 이미지

이동과 관련된 마우스 이벤트는 앞서 살펴본 클릭 이벤트와 두드러지는 차이점을 하나 가진다. 바로 relatedTarget이라는 프로퍼티를 추가로 가진다는 점이다. 이는 이벤트 객체가 기본적으로 가지고 있는 target 프로퍼티를 보완한다. 마우스 이동이 발생하는 지점은 항상 외부 <-> 내부의 흐름이 발생한다. 이때 어떤 이벤트냐에 따라 target은 외부 요소일 수도, 내부 요소일 수도 있다. target 요소가 정해짐에 따라 relatedTarget 역시 나머지 한 요소를 가리키게 된다.

  • mouseover의 경우
    • event.target : 현재 마우스 커서가 진입한 요소
    • event.relatedTarget : 현재 마우스 커서가 떠나는 요소 (relatedTarget → target)
  • mouseout의 경우
    • event.target : 현재 마우스 커서가 떠나는 요소
    • event.relatedTarget : 현재 마우스 커서가 진입한 요소 (target → relatedTarget)

마우스 이동 이벤트가 무엇이냐에 따라 그 정의가 서로 상반되는 것을 볼 수 있다. 정의는 상반되지만 그렇기 때문에 두 이벤트에서 각각의 프로퍼티가 가리키는 값은 동일하다.

이때 relatedTargetnull 값이 될 수도 있다. 만약 브라우저 요소에서가 아니라 브라우저 창 밖을 넘어가거나 HTML 요소로부터 접근이 된 것이 아닌 경우엔 null 값을 가지게 된다. 따라서 이 경우를 고려해 event.relatedTarget.tagName 과 같이 접근할 때 NullPointerException 등의 에러가 발생하지 않도록 주의해야 한다.

2) 요소 건너뛰기

이동과 관련된 마우스 이벤트인 mouseover/mouseout은 마우스 이동이 있을 때마다 발생한다. 그러나 모든 마우스 움직임에 대하여 1:1로 대응하지는 않는다.

브라우저는 마우스의 현재 위치를 주기적으로 체크를 하는데, 만약 해당 주기보다 더 빠르게 요소와 요소사이를 마우스로 이동하는 경우에는 지나친 요소들은 마우스 이동 이벤트를 잡아내지 못한다. 즉 매우 빠르게 움직이는 이동은 정상적으로 감지할 수 없다.

위 그림에서 #FROM 요소에서 #TO 요소로 이동할 때 매우 빠르게 이동한다면 그 사이에 위치한 DIV 요소들에서는 이 마우스 이동 이벤트를 잡아낼 수 없다. 이는 성능적인 측면에서 고려된 설계인데, 만약 마우스가 지나치는 모든 요소들이 이를 잡아낸다면 브라우저에 많은 부하가 걸리게 되기 때문이다.

이와 같은 특성 때문에 앞에서 언급했던 것과 같이 relatedTargetnull 값이 될 수 있다. 브라우저 창 외부에서 매우 빠르게 특정 요소로 마우스를 진입한다면 그 사이사이에 위치한 요소들은 정상적으로 이를 인지할 수 없기 때문이다.

만약 mouseover 이벤트가 발생했다면 항상 mouseout 이벤트가 따라서 발생하는 것이 보장되어야 한다. 아무리 빨리 마우스 이동을 한다 하더라도 진입과 이탈은 짝으로 발생하기 때문이다.

3) mouseout/over과 parent/child 요소

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 요소에서 mouseovermouseout 이벤트 두 개 모두 잡아내는 것을 확인할 수 있다. 이때 mouseover#child에서 발생한 것이고, mouseout#parent에서 발생한 것이다.

  1. mouseout [target: parent]이 먼저 발생하고
  2. mouseover [target: child]이 발생

따라서 mouseover/out 이벤트는 버블링이 디폴트로 일어나는 것을 확인할 수 있다. 때문에 만약 핸들러 내부에서 event.target을 구분하지 않는다면 특정 요소를 떠날때 곧바로 진입 이벤트가 발생하거나 그 반대의 경우 역시 발생할 수 있다.

그러나 애니메이션과 같은 동작이 오직 parent.onmouseout을 통해 완전히 외부로 벗어나는 경우에만 발생하고자 하는 경우에는 이 같은 버블링은 이를 방해할 수 있다. 왜냐하면 parent 내부에 다른 요소가 있는 경우, 해당 요소로 진입하는 경우에도 parent를 떠나는 것으로 잡아내기 때문이다. 따라서 이를 방지하기 위해서는 relatedTarget 프로퍼티를 이용하여 FROMTO를 세밀하게 정의하여 조건을 추가해야 할 것이다.

그렇지만 일일이 이를 조정하는 것 대신 mouseenter/mouseleave 이벤트를 사용하는 것도 좋은 대안이 될 수 있다.

4) mouseenter과 mouseleave

mouseenter/leave 이벤트는 그 의미에서 알 수 있듯이 어떤 요소에 진입 또는 이탈할 때 발생하는 이벤트다. 즉 발생 시점과 조건은 위에서 살펴본 mouseover/out 이벤트와 동일하다. 그러나 가장 큰 차이점은 해당 이벤트는 버블링이 되지 않는다는 점이다. 따라서 이벤트 위임 패턴을 사용할 수 없다. 또 하나의 차이점은 부모-자식 요소 사이에 전환은 취급하지 않는다는 점이다. 예를 들어 부모 내부에 있는 자식은 앞서 살펴본 것과 달리 새로운 단일 요소로 인식하지 않는다. 즉 부모 요소에 해당 이벤트 핸들러를 등록했다면, 부모 자체에서만 이벤트가 발생하는 것을 감지한다.

따라서 앞서 언급했던 문제는 mouseenter/leave 이벤트로 대체하는 것으로 손쉽게 해결할 수 있다. 이벤트 버블링이 일어나지 않고 등록된 요소 자체에서만 이벤트를 감지하기 때문에, parent.onmouseleave를 정의한다면 해당 요소 자체를 떠날 때만 특정 동작이 발생함을 보장할 수 있다.

5) 이벤트 위임

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 등의 특수한 이벤트와 함께 드래그 앤 드롭을 구현할 수 있다. 해당 이벤트는 운영체제의 파일 관리 애플리케이션으로부터 파일을 드래그해서 브라우저 화면에 드롭하는 특별한 기능 역시 제공한다. 그 이후 자바스크립트로 가져온 파일의 내용을 다룰 수 있다.

그러나 기본 드래그 이벤트엔 한계가 있다. 예를 들어 특정 영역에서 드래그하는 것을 막을 수 없다. 수평이나 수직으로만 드래그 하는 것 역시 제공되지 않는다. 모바일 환경에서의 지원이 부족하다는 것 역시 한계로 작용한다.

해당 챕터에서는 기본 드래그 이벤트의 한계를 극복하기 위해 마우스 이벤트로만 드래그 앤 드롭을 구현해보도록 하자.

1) 드래그 앤 드롭 알고리즘

드래그 앤 드롭의 알고리즘은 간단하다. 이를 마우스 이벤트와 연관지어보면 결국 mousedown ➡ mousemove ➡ mouseup 의 순서로 진행된다는 것을 알 수 있다.

  1. mousedown 에서는 움직임이 필요한 요소를 준비한다. 이때 기존 요소의 복사본을 만들거나, 해당 요소에 클래스를 추가하는 등의 작업을 할 수 있다.

  2. 이후 mousemove에서 특정 요소의 position: absoluteleft, top 등을 조정한다.

  3. 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 레벨에서 해당 이벤트를 관리하는 것이다.

2) 올바른 위치 지정

위 예제에서 공의 위치를 설정하는 코드는 아래와 같다.

ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
ball.style.top = pageY - ball.offsetHeight / 2 + 'px';

동작하는데는 문제가 없지만 부자연스러운 흐름이 있다. 드래그 앤 드롭을 시작하기 위해 공 위 어디에서든 mousedown을 할 수 있는데, 만약 공의 가장자리에 가깝게 mousedown을 하게 되면, 마우스 포인터 아래로 공이 갑작스럽게 이동하게 된다. 이는 사용자 경험에 부정적인 영향을 끼칠 수 있기 때문에, 포인터를 기준으로 요소의 초기 이동을 유지하는 방법이 더 좋다. 예를 들어 공의 가장자리에서 드래그 하기 시작했다면, 공을 드래그 하는 동안엔 포인터가 공의 가장자리에 유지돼야 한다.

이를 위해 공의 위치를 자연스롭게 조절하는 코드를 추가로 구현하자. 그림에서 보는 것과 같이 shiftX, shiftY 변수를 추가로 두어 관리할 수 있다.

  1. 방문자가 버튼을 눌렀을 때 shiftX, shiftY 변수를 통해 공의 왼쪽 위 모서리까지의 거리를 기억하게 하고, 드래그하는 동안 이 거리를 유지하게 하자. 거리를 유지하는 움직임은 포인터의 좌표에서 공의 왼쪽 위 좌표를 빼서 구할 수 있다.
let shiftX = event.clientX - ball.getBoundingClientRect().left;
let shiftY = event.clientY - ball.getBoundingClientRect().top;
  1. 공을 드래그하는 동안 포인터를 기준으로 같은 위치에 공을 이동시킨다.
ball.style.left = event.pageX - shiftX + 'px';
ball.style.top = event.pageY - shiftY + 'px';

위 두 개선된 코드를 앞서 구현한 코드에 추가하면 자연스러운 무브먼트를 보이는 공의 드래그 앤 드롭을 구현할 수 있다.

3) 잠재적 드롭 대상

지금까지 본 예제에서는 공을 어디에서나 드롭할 수 있었다. 그러나 파일을 폴더나 다른 곳에 드롭하듯 실생활에서는 보통 한 요소를 다른 요소에 드롭하는 경우가 많다. 즉 드래그 가능한 요소드롭 가능한 요소에 두는 행위가 보장된다. 이를 구현하기 위해 다음 두 가지를 살펴보자.

  • 해당 작업을 수행하기 위해 드래그 앤 드롭 끝에 요소가 드롭될 위치
  • 드롭 가능한 위치에 끌고 와 올려뒀을 때 드롭 할 수 있는지 알 수 있게 강조 표시

잠재적으로 놓을 수 있는 요소를 강조하기 위해서 간단히 생각할 수 있는 방법으로는 해당 요소에 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);
    }
  }
}

References

  1. https://ko.javascript.info/event-details
profile
개발잘하고싶다

0개의 댓글