캔버스 기반 앱에서 빈 공간을 드래그해 여러 객체를 한 번에 선택하는 기능, Marquee Selection을 구현했다. Figma나 Photoshop에서 익숙하게 쓰던 기능인데, 막상 직접 만들려니 생각보다 고려할 게 많았다.
문제 정의
요구사항은 명확했다:
- 빈 공간 드래그 → 반투명 선택 영역 표시
- 드래그 영역과 겹치는 레이어 모두 선택
- Shift+드래그 → 기존 선택에 추가
- 실시간 피드백 → 드래그 중에도 선택 상태 업데이트
제약 조건도 있었다:
- 캔버스는 Pixi.js로 렌더링 중
- 줌/팬이 적용된 상태에서도 정확하게 동작해야 함
- 기존 Hand 모드(패닝)와 Move 모드(레이어 이동)와 충돌 없이 공존해야 함
핵심 구현
1. 좌표 변환: 화면 → 캔버스
가장 먼저 해결해야 할 문제는 좌표 변환이다. 마우스 이벤트는 화면 좌표를 주는데, 캔버스는 줌/팬이 적용된 별도의 좌표계를 사용한다.
const screenToCanvas = (screenX: number, screenY: number) => {
const rect = container.getBoundingClientRect();
const x = (screenX - rect.left - pan.x) / zoom;
const y = (screenY - rect.top - pan.y) / zoom;
return { x, y };
};
순서가 중요하다:
- 컨테이너 기준 상대 좌표로 변환 (
- rect.left) - 팬 오프셋 제거 (
- pan.x) - 줌 역변환 (
/ zoom)
이 순서를 잘못하면 줌 레벨에 따라 위치가 어긋난다.
2. 교차 판정: AABB 충돌 검사
선택 영역과 레이어가 겹치는지 판정하는 건 단순한 AABB(Axis-Aligned Bounding Box) 충돌 검사로 충분하다.
function rectsIntersect(a: Rect, b: Rect): boolean {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
분리 축 정리(Separating Axis Theorem)의 축약 버전이다. 두 사각형이 어떤 축에서든 겹치지 않으면 교차하지 않는다. 네 조건 중 하나라도 거짓이면 교차하지 않음.
회전된 객체가 있다면 OBB(Oriented Bounding Box) 검사가 필요하지만, 이 프로젝트에서는 모든 레이어가 축 정렬되어 있어서 AABB로 충분했다.
3. 이벤트 충돌 처리
빈 공간 드래그를 두 기능이 사용한다:
- Hand 모드: 캔버스 패닝
- Move 모드: Marquee 선택
해결책은 모드에 따라 책임을 분리하는 것이다:
// usePixiDragPan.ts
const handleMouseDown = (e: MouseEvent) => {
if (tool === 'hand') {
// Hand 모드에서만 패닝 시작
isDraggingRef.current = true;
setDraggingCanvas(true);
}
// Move 모드: 패닝 없음, Marquee 훅이 처리
};
Move 모드에서 빈 공간 드래그는 usePixiDragPan이 무시하고, usePixiMarquee가 담당한다.
4. 레이어 드래그 vs Marquee 시작
더 까다로운 문제가 있었다. Move 모드에서:
- 레이어 위에서 드래그 → 레이어 이동
- 빈 공간에서 드래그 → Marquee 선택
mousedown 시점에는 둘을 구분할 수 없다. 클릭한 위치가 레이어인지 아닌지는 Pixi.js 내부에서 판정하기 때문이다.
해결책은 지연 시작이다:
const handleMouseMove = (e: MouseEvent) => {
// 레이어가 이미 포인터를 캡처했으면 Marquee 시작 안 함
const { isLayerPointerDown } = useUIStore.getState();
if (isLayerPointerDown) {
isMouseDownRef.current = false;
return;
}
// 5px 이상 이동해야 Marquee 시작
const dx = Math.abs(pos.x - startPosRef.current.x);
const dy = Math.abs(pos.y - startPosRef.current.y);
if (dx > 5 || dy > 5) {
isMarqueeActiveRef.current = true;
marqueeRef.current.start(startPosRef.current.x, startPosRef.current.y);
}
};
isLayerPointerDown: 레이어가 pointerdown을 받으면 true로 설정되는 전역 상태- 5px 임계값: 클릭과 드래그를 구분. 단순 클릭은 Marquee로 처리 안 함 <<<< 손떨방 ;;
삽질 포인트 😱
Pixi.js Graphics가 안 보이는 버그
처음 구현했을 때 마키 사각형이 간헐적으로 안 보이는 버그가 있었다. 원인을 추적해보니 Pixi.js Graphics의 특성 때문이었다.
// 문제: Graphics.clear() 후 바로 draw하면 가끔 렌더링 안 됨
start(x: number, y: number) {
this.container.clear();
this.container.visible = true;
this.draw(x, y); // 가끔 안 보임
}
해결책은 Graphics 객체를 새로 생성하는 것이었다:
start(x: number, y: number) {
// Graphics를 완전히 새로 생성
const parent = this.container.parent;
this.container.destroy();
this.container = new Graphics();
this.container.zIndex = 10000;
this.container.visible = false; // draw 완료 후에만 visible
if (parent) parent.addChild(this.container);
}
private draw(endX: number, endY: number) {
this.container.clear();
// ... 그리기 ...
// 그리기 완료 후에만 표시
if (!this.container.visible) {
this.container.visible = true;
}
}
Graphics 객체 재사용보다 비용이 크지만, 마키 선택은 빈번하게 일어나지 않아서 성능 영향은 무시할 수준이다.
줌 레벨에 따른 선 두께
줌 아웃하면 마키 테두리가 너무 가늘어지고, 줌 인하면 너무 두꺼워지는 문제가 있었다.
// 화면에서 항상 1px로 보이도록 보정
const borderWidth = Math.max(1, 1 / this.zoom);
this.container.stroke({ width: borderWidth, color, alpha: 1 });
캔버스 좌표계에서 1 / zoom 두께로 그리면, 화면에서는 항상 1px로 보인다. Math.max(1, ...) 는 줌 인 시 서브픽셀 렌더링으로 인한 흐릿함을 방지한다.
상태 관리 확장
기존에는 단일 선택만 지원했다:
// Before
interface SelectionState {
dragTarget: string | null;
}
다중 선택을 위해 확장하면서 역호환성을 유지했다:
// After
interface SelectionState {
selectedIds: Set<string>; // 새로운 다중 선택
primarySelectedId: string | null; // 마지막 선택된 레이어 (스냅 기준)
dragTarget: string | null; // @deprecated 역호환용
}
// 역호환 래퍼
setDragTarget: (id) => {
if (id === null) clearSelection();
else select(id);
}
primarySelectedId는 스냅 가이드 기준점으로 사용된다. 여러 레이어가 선택되어도 스냅은 마지막 선택 레이어 기준으로 동작한다.
Shift+드래그: 기존 선택에 추가
Shift 키를 누르고 드래그하면 기존 선택을 유지하면서 새로 교차하는 레이어를 추가해야 한다.
// 드래그 시작 시 기존 선택 저장
if (shiftKeyRef.current) {
initialSelectionRef.current = new Set(
useSelectionStore.getState().selectedIds
);
} else {
initialSelectionRef.current.clear();
clearSelection();
}
// 드래그 중 실시간 업데이트
const intersecting = findIntersectingLayers(rect, allLayers);
if (shiftKeyRef.current) {
// 기존 선택 + 새로 교차하는 레이어
const combined = new Set(initialSelectionRef.current);
intersecting.forEach((id) => combined.add(id));
selectMultiple(Array.from(combined));
} else {
selectMultiple(intersecting);
}
핵심은 드래그 시작 시점에 기존 선택을 스냅샷으로 저장하는 것이다. 드래그 중에 선택이 계속 바뀌기 때문에 시작 시점 상태를 기억해야 한다.
Marquee Selection은 구현 자체보다 기존 시스템과의 통합이 더 어려웠다. 특히:
- 좌표 변환 순서 - 줌/팬이 적용된 캔버스에서 정확한 좌표 계산
- 이벤트 충돌 - 같은 동작(빈 공간 드래그)을 여러 기능이 사용할 때의 책임 분리
- 상태 전이 타이밍 - 레이어 클릭 vs 빈 공간 드래그를 구분하는 시점
결국 패턴은 지연 판정과 전역 상태 플래그로 수렴했다. mousedown에서 바로 동작을 결정하지 않고, 이동 거리나 다른 컴포넌트의 상태를 확인한 후 결정하는 방식이다.
캔버스 기반 에디터를 만든다면, 이런 이벤트 충돌 처리 패턴을 초기에 설계해두는 게 좋다. 나중에 기능이 늘어날수록 충돌 케이스가 기하급수적으로 늘어난다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd' 카테고리의 다른 글
| Spring Physics로 오뚜기 애니메이션 구현하기 (0) | 2026.03.10 |
|---|---|
| shadcn/ui 도입기: Headless UI로 컴포넌트 제어권 되찾기 (0) | 2026.02.24 |
| Pixi.js 다중 선택 드래그 구현하기 (0) | 2026.02.19 |
| OffscreenCanvas로 Service Worker에서 이미지 처리하기 (0) | 2026.02.10 |
| next-intl 없이 i18n 직접 만들기 (0) | 2026.02.07 |
