Pixi.js로 캔버스 에디터를 만들면서 가장 기본이면서도 까다로운 기능이 드래그였다. 단일 객체 드래그는 간단하지만, 여러 객체를 동시에 드래그하면서 클릭과 드래그를 구분하고, 부드러운 움직임까지 보장하려면 생각보다 고려할 게 많다.
FederatedEvents: Pixi.js의 이벤트 시스템
Pixi.js v7부터 FederatedEvents라는 이벤트 시스템을 사용한다. DOM의 PointerEvent와 유사한 API를 제공하면서, 캔버스 내부 객체들의 이벤트 전파를 처리한다.
FederatedPointerEvent는 DOM PointerEvent를 Pixi.js 씬 그래프에 맞게 래핑한 것이다.
stopPropagation(),shiftKey같은 익숙한 속성들을 그대로 사용할 수 있다.
기본적인 드래그는 세 이벤트의 체인으로 구성된다:
container.on('pointerdown', this.onDragStart.bind(this));
container.on('globalpointermove', this.onDragMove.bind(this));
container.on('pointerup', this.onDragEnd.bind(this));
container.on('pointerupoutside', this.onDragEnd.bind(this));
여기서 pointermove가 아닌 globalpointermove를 사용한 이유가 있다. pointermove는 해당 객체 위에서만 발생하지만, globalpointermove는 마우스가 객체 바깥으로 나가도 계속 발생한다. 빠르게 드래그할 때 마우스가 객체를 벗어나는 경우가 흔하기 때문에, 끊김 없는 드래그를 위해 필수다.
Drag Offset: 부드러운 드래그의 핵심
드래그를 구현할 때 흔히 하는 실수가 있다. 마우스 위치를 그대로 객체 위치로 설정하는 것이다.
// ❌ 잘못된 방식
container.position.set(mouseX, mouseY);
이렇게 하면 드래그 시작 순간 객체가 마우스 위치로 "점프"한다. 객체의 모서리를 클릭했는데 갑자기 중앙이 마우스 아래로 오는 식이다.
올바른 방식은 드래그 시작 시점의 offset을 저장하고, 매 프레임마다 이 offset을 적용하는 것이다:
private onDragStart(e: FederatedPointerEvent) {
const parent = this.container.parent;
const pos = e.getLocalPosition(parent);
// 마우스와 컨테이너 사이의 거리 저장
this.dragOffset = {
x: pos.x - this.container.position.x,
y: pos.y - this.container.position.y,
};
}
private onDragMove(e: FederatedPointerEvent) {
const pos = e.getLocalPosition(parent);
// offset을 빼서 원래 위치 관계 유지
const newX = pos.x - this.dragOffset.x;
const newY = pos.y - this.dragOffset.y;
this.container.position.set(newX, newY);
}
getLocalPosition(parent)는 부모 컨테이너 좌표계에서의 위치를 반환한다. 전역 좌표가 아닌 부모 기준 로컬 좌표를 사용해야 줌/팬이 적용된 캔버스에서도 정확한 위치 계산이 가능하다.
클릭과 드래그 구분하기
객체를 선택하는 "클릭"과 위치를 옮기는 "드래그"는 모두 pointerdown으로 시작한다. 이 둘을 구분하지 않으면 클릭할 때마다 onMove 콜백이 호출되어 불필요한 상태 업데이트가 발생한다.
private isDragging = false;
private hasMovedWhileDragging = false;
private onDragStart(e: FederatedPointerEvent) {
this.isDragging = true;
this.hasMovedWhileDragging = false;
// 선택 처리는 즉시 실행
if (this.onSelect) {
this.onSelect(this.id, e.shiftKey);
}
}
private onDragMove(e: FederatedPointerEvent) {
if (!this.isDragging) return;
// 첫 이동 감지
if (!this.hasMovedWhileDragging) {
this.hasMovedWhileDragging = true;
if (this.onDragStateChange) {
this.onDragStateChange(true);
}
}
// 위치 업데이트...
}
private onDragEnd() {
if (this.isDragging && this.hasMovedWhileDragging) {
// 실제로 이동이 발생했을 때만 onMove 호출
if (this.onMove) {
this.onMove(this.id, { x: this.container.position.x, y: this.container.position.y });
}
}
this.isDragging = false;
this.hasMovedWhileDragging = false;
}
hasMovedWhileDragging 플래그 덕분에:
- 단순 클릭 →
onSelect만 호출 - 드래그 →
onSelect+onDragStateChange(true)+onMove+onDragStateChange(false)
이 구분은 undo/redo 히스토리 관리에서 중요하다. 클릭할 때마다 히스토리가 쌓이면 안 되고, 실제 이동이 발생했을 때만 쌓여야 한다.
다중 선택 드래그: Delta 방식
여러 객체를 동시에 드래그하는 방법에는 두 가지 접근이 있다:
| 방식 | 장점 | 단점 |
|---|---|---|
| 절대 위치 전파 | 단순한 구현 | 모든 객체의 초기 위치를 알아야 함 |
| Delta(상대 이동) 전파 | 유연함, 스냅 적용 용이 | 드래그 시작 시점 기준 계산 필요 |
Delta 방식을 선택한 이유는 스냅 가이드 때문이다. 스냅이 적용되면 주 드래그 객체의 위치가 조정되는데, 이 조정량을 다른 객체들에도 동일하게 적용해야 한다. 절대 위치 방식으로는 이 계산이 복잡해진다.
// 드래그 시작 시 모든 선택된 레이어의 초기 위치 저장
dragStartPositionsRef.current.clear();
currentSelectedIds.forEach((layerId) => {
const layer = layersRef.current.get(layerId);
if (layer) {
dragStartPositionsRef.current.set(layerId, {
x: layer.data.position.x,
y: layer.data.position.y,
});
}
});
// 드래그 중: delta 계산 후 모든 레이어에 적용
const primaryStartPos = dragStartPositionsRef.current.get(id);
const currentDelta = {
x: pos.x - primaryStartPos.x,
y: pos.y - primaryStartPos.y,
};
// 스냅 적용 후 보정된 delta 계산
const snappedDelta = {
x: snappedPos.x - primaryStartPos.x,
y: snappedPos.y - primaryStartPos.y,
};
// 다른 선택된 레이어들에 동일한 delta 적용
currentSelectedIds.forEach((layerId) => {
if (layerId === id) return; // 주 드래그 객체는 제외
const startPos = dragStartPositionsRef.current.get(layerId);
const layer = layersRef.current.get(layerId);
if (layer && startPos) {
layer.setPosition(
startPos.x + snappedDelta.x,
startPos.y + snappedDelta.y
);
}
});
핵심은 드래그 시작 시점의 위치를 기준으로 delta를 계산한다는 것이다. 매 프레임마다 이전 프레임 대비 delta를 누적하는 방식은 부동소수점 오차가 쌓여 위치가 틀어질 수 있다. 항상 시작점 기준으로 계산해야 정확하다.
이 패턴들은 Pixi.js뿐 아니라 Canvas 2D, SVG, 심지어 DOM 요소 드래그에도 동일하게 적용할 수 있다. 좌표계와 이벤트 시스템만 다를 뿐, 문제 해결 방식은 같다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd' 카테고리의 다른 글
| shadcn/ui 도입기: Headless UI로 컴포넌트 제어권 되찾기 (0) | 2026.02.24 |
|---|---|
| Marquee Selection (범위 선택) 구현하기 (0) | 2026.02.21 |
| OffscreenCanvas로 Service Worker에서 이미지 처리하기 (0) | 2026.02.10 |
| next-intl 없이 i18n 직접 만들기 (0) | 2026.02.07 |
| iframe 키 이벤트 재합성으로 Cross-Origin 우회하기 (0) | 2026.02.05 |
