- Red Blob Games의 인터랙티브 다이어그램은 독자가 객체를 직접 움직이면 화면이 즉시 반응해야 하므로, 입력 처리를 Pointer Events 중심으로 정리해 마우스와 터치를 함께 다룸
- 예전의
mousedown/mouseup/mousemove방식은 포인터가 객체 밖으로 벗어나거나 밖에서 버튼을 놓을 때 드래그 상태가 꼬일 수 있어, 전역 핸들러 같은 보완이 필요했음 - 터치 입력은 이벤트 전달 방식이 마우스와 다르며, 한 손가락 드래그가 페이지 스크롤까지 유발하므로
touchstart에서preventDefault()를 호출해 충돌을 줄임 - 최종 레시피는
pointerdown,pointermove,pointerup,pointercancel과setPointerCapture()를 조합하고, 왼쪽 버튼과 시작 오프셋을 확인해 객체가 갑자기 튀지 않게 함 - 텍스트·이미지 포함 객체, 다중 터치, 중첩 드래그,
<canvas>, hover와 drag 조합,lostpointercapture같은 플랫폼별 예외는 실제 UI에서 별도 처리가 필요함
드래그 입력 핸들러의 목표
- 많은 인터랙티브 페이지에는 독자가 직접 움직이는 드래그 가능한 객체가 있고, 다이어그램은 객체 위치 변화에 반응해야 함
- 목표는 마우스와 터치 입력 모두에서 동작하는 입력 이벤트 핸들러를 만드는 것임
- 기본 흐름은
pointerdown에서 드래그를 시작하고,pointermove에서 위치를 갱신하며,pointerup또는 취소 이벤트에서 드래그를 끝내는 구조임 - 실제 구현에서는 마우스 버튼, 여러 손가락 터치, 이벤트 전달 대상, 우클릭 컨텍스트 메뉴 때문에 단순 모델보다 복잡해짐
- 기본 레시피는 다음 요소를 조합함
pointerdown에서event.button !== 0이면 반환해 기본 버튼만 처리- 이벤트 좌표를 다이어그램 좌표로 변환
- 시작 시 객체 위치와 포인터 위치의 차이인
dx,dy저장 el.setPointerCapture(event.pointerId)로 포인터 캡처pointermove에서 저장된 오프셋을 더해state.pos갱신pointerup,pointercancel에서state.dragging = nulltouchstart에서preventDefault()호출
- 이 레시피는 입력 이벤트 핸들러이며, 실제 출력 처리는 별도의 상태 핸들러와 연결해야 함
- 완전한 코드는 examples page에 있으며, 객체 이동이 아닌 숫자 좌우 드래그, 캔버스 페인팅, 제한된 드래그 예도 포함됨
- 테스트 범위는 Gecko/Firefox, Blink/Chrome, WebKit/Safari의 Mac, Windows, Linux, Android, iPhone, iPad임
- hover 가능한 stylus, 하이브리드 터치+마우스 장치, 음성 입력은 테스트하지 않음
- 이 방식은 요소를 다른 요소 위에 놓는 HTML Drag and Drop API가 아니며, 다이어그램에서는 마우스·터치 이벤트를 직접 읽어야 함
마우스 이벤트만 사용할 때의 한계
- 초기 구현은
mousedown,mouseup,mousemove를 드래그 요소에 직접 붙이는 방식이었음 - 겉보기에는 동작하지만 두 가지 문제가 생김
- 포인터를 빠르게 움직이면 더 이상 원 위에 있지 않아 이벤트를 받지 못함
- 원 밖에서 버튼을 놓으면
dragging상태에 갇힐 수 있음
- 개선책은
mousedown은 원에서 받고, 드래그 중mousemove와mouseup은document에 임시로 붙이는 방식임 mouseup에서document에 붙인mousemove,mouseup핸들러를 제거하면 마우스 드래그가 더 안정적으로 동작함- 이 방식은 마우스만 처리하며 터치 이벤트는 다루지 않음
터치 이벤트의 차이
- 터치 입력은
mousedown,mouseup,mousemove대신touchstart,touchend,touchmove를 사용함 - 터치 이벤트는
touchstart이후 자동으로 캡처되어 모든touchmove가 원래 요소로 전달됨 - 이 특성 때문에 마우스 이벤트처럼
document에 전역 이벤트 핸들러를 임시로 붙일 필요가 없음 - 브라우저가 터치 시퀀스를 취소해야 할 때는
touchcancel을 보냄 - 터치 드래그 구현은
touchstart에서preventDefault()를 호출하고,touchmove에서event.changedTouches[0]좌표로 위치를 갱신함 - 이 방식은 터치만 처리하며 마우스 이벤트는 처리하지 않음
Pointer Events로 마우스와 터치 통합
- 마우스와 터치를 모두 처리하려면 많은 이벤트 핸들러가 필요했고, 2021년 전에는 그런 방식을 사용했음
- 2011~2014년에는 d3 프로젝트에서 d3-drag를 사용했고, d3를 쓰지 않는 프로젝트에서는 별도 마우스+터치 코드를 만들었음
- Pointer Events는 마우스와 터치 처리를 통합하고 단순화함
- MS IE는 2012년에 지원을 추가
- Chrome은 2017년, Firefox는 2018년, Safari는 2020년에 지원을 추가
- 브라우저 동작은 시간이 지나며 바뀌었고, 2017년 Chrome은 일부 이벤트를 기본 passive mode로 바꿔 객체를 드래그하려 할 때 페이지가 스크롤되는 문제가 생김
- Safari와 Firefox도 2018년에 같은 변경을 적용함
- pointer capture를 쓰면 마우스 이벤트에서 필요했던
document전역 핸들러 추가·제거 없이 단순한 로직으로 처리할 수 있음
Pointer Capture로 포인터 추적 유지
- Pointer capture는 포인터가 원, 다이어그램, 브라우저 창 밖으로 벗어나도 추적을 계속할 수 있게 함
- 마우스 이벤트에서는 같은 효과를 위해
document에 이벤트 핸들러를 붙여야 했지만, Pointer Events에서는setPointerCapture(event.pointerId)가 더 단순함 - 포인터 캡처가 없으면 빠르게 드래그하거나 다이어그램 밖으로 나갔다가 돌아올 때 드래그가 멈출 수 있음
- 다이어그램 밖에서 버튼을 놓거나 Alt+Tab으로 다른 창으로 이동하는 경우도 캡처 유무에 따라 드래그 종료 처리가 달라짐
- 터치 장치에서는 포인터 캡처가 기본 동작이지만, 마우스에서는 명시적으로 설정해야 함
터치 스크롤과 preventDefault()
- 터치 장치에서 한 손가락 드래그는 기본적으로 페이지 스크롤을 일으킴
- 같은 한 손가락 드래그가 원도 움직이면 스크롤과 객체 드래그가 동시에 발생함
- 가장 단순한 해결책은 다이어그램에 CSS
touch-action: none을 적용하는 것이지만, 이 경우 다이어그램 어느 곳에서도 스크롤할 수 없음 - 원하는 동작은 원을 드래그할 때만 스크롤을 막고, 다이어그램 자체를 드래그할 때는 스크롤을 허용하는 것임
- 비교 결과는 다음과 같음
- 다이어그램 전체에
touch-action: none: 원 드래그와 다이어그램 드래그 모두 스크롤 중단 - 기본값: 둘 다 스크롤을 막지 못함
- 원에만
touch-action: none: 기대한 대로 동작하지 않음 touchstart에서preventDefault(): 원 드래그 중 스크롤만 막고 다이어그램 스크롤은 허용
- 다이어그램 전체에
preventDefault()는pointerstart가 아니라touchstart에 붙임- 이 방식은 대부분 상황에서 동작하지만, desktop Firefox의 “mobile emulation mode”에서는 동작하지 않았고 Android의 mobile Firefox에서는 동작함
드래그 시작 오프셋 저장
- 객체의 가장자리를 잡아 드래그할 때 객체 중심이 포인터 위치로 순간 이동하면 사용감이 나빠짐
- 해결책은 드래그 시작 시 객체 중심과 포인터 위치의 상대 위치를 저장하는 것임
- 이후 이동할 때 현재 포인터 좌표에 저장한
dx,dy를 더해 객체 위치를 계산함 - 이 방식은
state.dragging을 단순true/false가 아니라{dx, dy}같은 상대 위치 정보로 바꿈 - 마우스에서는 가장자리에서 잡을 때 점프 차이가 잘 보이고, 터치에서는 손가락이 가려서 덜 보일 수 있음
- 관련된 작은 디테일은 little details 페이지에도 정리되어 있음
컨텍스트 메뉴와 마우스 버튼
- 컨텍스트 메뉴 활성화 방식은 플랫폼마다 다름
- Windows: 우클릭 down+up, Shift+F10
- Linux: 오른쪽 버튼 down, Shift+F10
- Mac: 오른쪽 버튼 down, Ctrl+왼쪽 클릭
- iOS: 텍스트에서만 long press
- Android: 어디서든 long press
- 문제는
pointerdown은 보이지만pointerup은 항상 보이지 않는 경우가 있다는 점임 - 버튼이 실제로는 올라갔는데 코드에서는 아직 눌린 상태로 판단할 수 있음
- 대응 방식은 오른쪽 버튼을 무시하고 왼쪽 마우스 버튼에서만
dragging상태를 설정하는 것임 - Pointer Events 명세의
button속성에서button = 0은 primary button을 뜻하며, 이를 사용해 가운데·오른쪽 버튼을 제외함 - Mac의 Chrome과 Safari에서는 Ctrl+클릭이 왼쪽 버튼
pointerdown처럼 들어올 수 있어event.ctrlKey도 확인해야 함 - 선택지는 다음과 같음
contextmenu에서preventDefault()로 메뉴를 막을 수 있지만, 기본 중간 클릭이나 드래그 동작과 충돌할 수 있음- 왼쪽 버튼만 드래그하면 대부분의 우클릭 차이를 피할 수 있음
- Mac Ctrl+클릭까지 피하려면
ctrlKey도 제외해야 함
- 버튼 변화가
pointerdown이나pointerup으로 전달되지 않아도pointermove의 버튼 상태로 일부 확인할 수 있음
텍스트와 이미지가 포함된 객체
- 드래그 가능한 요소 안에 텍스트나 이미지가 있으면 추가 처리가 필요함
- 원을 드래그하는 동안 내부 텍스트가 선택될 수 있음
- 해결책은 CSS
user-select: none을 적용하는 것임- 항상 적용하면 내부 텍스트가 언제나 선택되지 않음
- 드래그 중에만 적용하면 평소에는 텍스트 선택이 가능함
- 2023년 초 기준 Safari는 unprefixed
user-select를 아직 지원하지 않아webkitUserSelect도 함께 설정해야 함 - Windows, Linux, Mac은 텍스트와 이미지의 애플리케이션 간 drag and drop을 지원하며, 이것이 객체 드래그와 충돌함
dragstart에서preventDefault()를 호출하면 선택된 텍스트나 이미지 드래그보다 객체 드래그를 우선할 수 있음- 브라우저와 운영체제에 따라 동작은 조금씩 다름
여러 포인터와 동시 드래그
- 여러 손가락이나 여러 마우스로 여러 객체를 동시에 드래그할 수 있는지는 엣지 케이스임
- 터치에서는 제시된 코드가 여러 객체를 동시에 드래그하는 데 이미 동작함
- 두 손가락으로 같은 객체를 드래그하는 경우는 별도 처리가 필요함
- 해결책은
pointerdown에서event.pointerId를state.dragging또는 상태에 저장하고,pointermove에서 같은pointerId가 아니면 무시하는 것임 - 이 구현 예시는 본문에 완전히 적용되어 있지 않고, canvas dragging test에서 확인할 수 있음
- 마우스의 경우 Pointer Events 명세는 일반 운영체제와 사용자 에이전트가 여러 마우스 입력 개념을 보통 갖지 않는다고 밝힘
- 트랙패드와 외장 마우스가 함께 있어도 일반적으로 하나의 마우스 포인터로 취급되므로, 서로 다른 마우스로 다른 객체를 드래그할 방법은 없다고 판단함
여러 마우스 버튼을 함께 누를 때
- 여러 마우스 버튼을 동시에 누르면 Pointer Events와 Mouse Events의 동작이 달라짐
- Mouse Events는 각 버튼 누름마다
mousedown, 각 버튼 해제마다mouseup을 보냄 - Pointer Events 명세에 따르면 처음 눌린 버튼에서
pointerdown, 마지막으로 해제된 버튼에서pointerup이 발생함 - 이 때문에
pointerdown과pointerup이 서로 다른 버튼 상태에서 발생할 수 있음 - 예를 들어 왼쪽 버튼을 누르고, 오른쪽 버튼을 누른 뒤, 왼쪽 버튼을 놓으면 객체가 계속 드래그 상태로 남을 수 있음
- 해결책은
pointermove에서event.buttons & 1로 왼쪽 버튼 상태를 확인하고, 더 이상 눌려 있지 않으면end(event)를 호출하는 것임 - 포인터 캡처는 모든 버튼을 놓을 때까지 계속될 수 있으며, 명시적으로 캡처를 해제하지 않는 한 이어질 수 있음
- 이 구현은 이 문제와 여러 엣지 케이스를 모두 처리하지는 않음
중첩 드래그 요소
- 드래그 가능한 요소 안에 또 다른 드래그 가능한 요소가 있으면 안쪽과 바깥쪽 요소가 모두 드래그를 처리함
- 해결책은 안쪽 드래그 요소에서
event.stopPropagation()을 호출해 이벤트가 바깥 드래그 요소로 올라가지 않게 하는 것임 pointerdown과pointermove에서 모두 전파를 중단하는 방식이 쓰임- 데모는 본문에는 없지만, Vue playground 예시에서 빨간 드래그 요소가 노란 드래그 요소의 자식인 사례를 확인할 수 있음
Canvas에서의 드래그
- 보통 SVG를 사용하지만,
<canvas>에서는 드래그 가능한 요소 자체에 이벤트 핸들러나 마우스 포인터 모양을 설정할 수 없음 - 2D Canvas나 WebGL에서는 이벤트 핸들러를
<canvas>에 붙이고, 좌표가 드래그 가능한 객체 위인지 직접 판정해야 함 - 처리 방식은 다음과 같음
pointerdown,touchstart,dragstart: 드래그 가능한 객체 위가 아니면 즉시 반환pointermove: 드래그 가능한 객체 위인지에 따라 커서 설정
- 데모는 examples page에 있음
마우스 Hover와 터치 Drag 조합
- 어떤 다이어그램은 마우스에서는 버튼을 누르지 않은 hover로 반응하고, 터치 장치에서는 hover가 없으므로 drag로 반응해야 함
- 기본 레시피는 마우스와 터치 모두 drag를 기대함
- 마우스 hover와 터치 drag를 함께 쓰려면
pointermove에서if (!state.dragging)조건을 제거함 - Responsive Design page에서는 마우스로 행 위에 hover하면 레이아웃이 바뀌고, 터치에서는 drag로 레이아웃이 바뀜
- Hexagons Guide에서도 많은 다이어그램이 마우스 hover와 터치 drag를 함께 사용함
- 터치에서 항목 A에서 B로 드래그할 때 캡처가 유지되면 move 이벤트가 A로 전달되므로, B를 하이라이트하려면 캡처 해제가 필요할 수 있음
Toggle Paint와 드래그 상태 확장
- Rounded Cell Painter에서는
pointerdown이 초기 페인트 색상을 캡처함 - 이후
pointerup까지 모든pointermove는 같은 페인트 색상을 사용함 - 이를 위해
state.dragging은 초기x,y뿐 아니라 초기 페인트 색상도 포함해야 함 - 데모는 examples page에 있음
lostpointercapture 엣지 케이스
lostpointercapture는 포인터 캡처를 잃었는지 감지하는 데 사용할 수 있음- 어떤 상황에서 발생하고 무엇을 해야 하는지는 아직 완전히 정리되지 않음
- 댓글에서 다룬 시나리오는 다음과 같음
- 왼쪽 버튼으로 원 드래그 시작
- 왼쪽 버튼을 누른 채 드래그 영역 밖으로 이동
- 오른쪽 버튼을 누른 뒤 왼쪽 버튼을 놓음
- 드래그 영역 밖 아무 곳이나 클릭
- standard는 포인터 캡처가 해제된 뒤 사용자 에이전트가
lostpointercapture이벤트를 반드시 발생시켜야 한다고 함 - 실제 브라우저 동작은 크게 다르며, 일부 시스템에서는
lostpointercapture가 발생하지만 일부는 다른 곳을 클릭할 때까지 발생하지 않음