이 글은 누구를 위한 것인가
- 모바일에서 요소를 배치·편집하는 UI(보드, 다이어그램, 콜라주)를 만드는 팀
- 데스크톱 편집기를 모바일에 이식하려는데 제스처 설계에서 막힌 경우
- 드래그와 스크롤이 서로 방해하는 문제를 겪는 개발자
들어가며
데스크톱 편집기에는 입력이 넉넉하다. 좌클릭, 우클릭, 휠, Shift·Ctrl 조합, 호버까지. 모바일에는 손가락 하나 또는 둘뿐이다.
그래서 모바일 편집기 설계는 기능을 옮기는 문제가 아니라, 적은 입력 채널에 동작을 어떻게 배분할 것인가의 문제다.
[하나의 손가락 드래그가 가리킬 수 있는 것]
· 캔버스 팬
· 요소 이동
· 영역 선택 (마퀴)
· 스크롤
→ 넷 중 무엇인지 구분하지 못하면 전부 오작동한다
이 글은 그 구분을 어떻게 짓는지에서 시작한다.
1. 제스처 충돌 — 무엇으로 구분할 것인가
[구분 축 세 가지]
① 시작 지점 빈 캔버스에서 시작 → 팬 / 요소 위에서 시작 → 이동
② 손가락 수 1개 → 이동·팬 / 2개 → 줌·회전
③ 모드 선택 모드 / 편집 모드를 명시적으로 전환
①과 ②의 조합이 기본이고, ③은 최후 수단이다. 모드 전환은 사용자가 현재 모드를 기억해야 해서 인지 비용이 크다.
[권장 배분]
요소 위 1손가락 드래그 → 그 요소 이동
빈 곳 1손가락 드래그 → 캔버스 팬
2손가락 → 핀치 줌 + 팬 (동시)
요소 위 롱프레스 → 다중 선택 시작
빈 곳 롱프레스 후 드래그 → 영역 선택(마퀴)
"빈 곳 드래그 = 팬"이 모바일의 기본값이어야 한다. 데스크톱에서는 빈 곳 드래그가 영역 선택인 경우가 많지만, 모바일에서 팬을 다른 곳에 배정하면 캔버스 이동이 불편해진다. 영역 선택은 롱프레스라는 추가 신호를 요구한다.
2. 브라우저 기본 제스처를 먼저 끈다
touch-action을 지정하지 않으면 브라우저가 스크롤·확대를 먼저 가로챈다.
.canvas-viewport {
touch-action: none; /* 모든 기본 제스처 차단 */
overscroll-behavior: contain; /* 당겨서 새로고침 방지 */
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none; /* iOS 롱프레스 시스템 메뉴 차단 */
}
-webkit-touch-callout: none이 iOS에서 중요하다. 이게 없으면 롱프레스로 다중 선택을 시작하려는 순간 시스템의 복사/공유 메뉴가 뜬다.
touch-action: none을 걸면 preventDefault() 없이도 기본 동작이 차단되므로, 리스너를 passive: true로 둘 수 있다. 성능상 유리하다.
[주의]
touch-action: none 은 페이지 전체가 아니라 캔버스 영역에만 건다
전체에 걸면 툴바·설정 패널의 스크롤까지 막힌다
3. 멀티터치 — 포인터를 개별로 추적한다
핀치 줌을 구현하려면 활성 포인터를 Map으로 관리해야 한다. 포인터별 식별(pointerId)과 이벤트 순서는 Pointer Events 문서에 정의돼 있다.
const pointers = new Map<number, { x: number; y: number }>();
let pinchStart: { dist: number; center: Point; scale: number } | null = null;
function onPointerDown(e: PointerEvent) {
(e.target as Element).setPointerCapture(e.pointerId);
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
pinchStart = {
dist: Math.hypot(b.x - a.x, b.y - a.y),
center: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
scale: viewport.scale,
};
cancelDrag(); // ★ 진행 중이던 1손가락 드래그를 취소
}
}
function onPointerMove(e: PointerEvent) {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2 && pinchStart) {
const [a, b] = [...pointers.values()];
const dist = Math.hypot(b.x - a.x, b.y - a.y);
const center = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
applyZoom(pinchStart.scale * (dist / pinchStart.dist), center);
applyPan(center.x - pinchStart.center.x, center.y - pinchStart.center.y);
} else if (pointers.size === 1) {
handleSingleDrag(e);
}
}
function onPointerUp(e: PointerEvent) {
pointers.delete(e.pointerId);
if (pointers.size < 2) pinchStart = null;
}
두 번째 손가락이 닿는 순간 진행 중인 드래그를 취소하는 것이 핵심이다. 이게 없으면 핀치를 시작할 때 요소가 딸려 움직인다. 그리고 취소는 "확정"이 아니라 원위치 복귀여야 한다.
pointercancel 처리도 빠뜨리면 안 된다. 시스템 제스처(iOS 스와이프 백)나 전화 수신으로 포인터가 취소되면, 정리하지 않은 상태가 남는다.
el.addEventListener('pointercancel', (e) => {
pointers.delete(e.pointerId);
pinchStart = null;
cancelDrag(); // 취소 = 되돌리기
});
4. 핀치 줌 — 두 손가락 중심을 고정한다
줌은 두 손가락의 중점 아래 콘텐츠가 제자리에 있어야 자연스럽다.
function applyZoom(nextScale: number, screenCenter: Point) {
const s = clamp(nextScale, 0.25, 4);
const rect = viewportEl.getBoundingClientRect();
const cx = screenCenter.x - rect.left;
const cy = screenCenter.y - rect.top;
// 중점의 world 좌표를 줌 전후로 고정
const wx = (cx - viewport.offsetX) / viewport.scale;
const wy = (cy - viewport.offsetY) / viewport.scale;
viewport = {
scale: s,
offsetX: cx - wx * s,
offsetY: cy - wy * s,
};
}
배율 상하한(0.25 ~ 4)은 반드시 둔다. 모바일에서는 실수로 크게 벌리는 일이 잦아서, 상한이 없으면 콘텐츠를 잃어버린다.
"화면에 맞추기" 버튼을 제공하는 것이 상한만큼 중요하다. 사용자가 길을 잃었을 때 돌아올 단일 버튼이 있어야 한다.
function fitToContent(cards: Card[], rect: DOMRect, pad = 40) {
if (!cards.length) return { scale: 1, offsetX: 0, offsetY: 0 };
const minX = Math.min(...cards.map(c => c.x));
const minY = Math.min(...cards.map(c => c.y));
const maxX = Math.max(...cards.map(c => c.x + c.w));
const maxY = Math.max(...cards.map(c => c.y + c.h));
const s = clamp(Math.min(
(rect.width - pad * 2) / (maxX - minX),
(rect.height - pad * 2) / (maxY - minY),
), 0.25, 1);
return {
scale: s,
offsetX: pad - minX * s + (rect.width - pad * 2 - (maxX - minX) * s) / 2,
offsetY: pad - minY * s + (rect.height - pad * 2 - (maxY - minY) * s) / 2,
};
}
5. 롱프레스 — 임계값이 전부다
롱프레스는 시간과 이동 거리 두 조건을 동시에 봐야 한다.
const LONG_PRESS_MS = 450;
const MOVE_TOLERANCE = 10; // CSS px
function startLongPressWatch(e: PointerEvent, onFire: () => void) {
const origin = { x: e.clientX, y: e.clientY };
const timer = setTimeout(() => {
onFire();
navigator.vibrate?.(10); // 발동 피드백
}, LONG_PRESS_MS);
const cancelOnMove = (ev: PointerEvent) => {
if (Math.hypot(ev.clientX - origin.x, ev.clientY - origin.y) > MOVE_TOLERANCE) {
clearTimeout(timer); // 움직였으면 롱프레스 아님 → 드래그
cleanup();
}
};
// ...
}
MOVE_TOLERANCE가 없으면 롱프레스가 거의 발동하지 않는다. 손가락은 가만히 있어도 몇 픽셀 흔들린다. 반대로 너무 크면(30px 이상) 드래그하려다 롱프레스가 걸린다. 10px 안팎이 무난하다.
진동 피드백이 특히 중요하다. 롱프레스는 시각 변화 없이 시간만 흐르는 제스처라, 언제 발동했는지 알 방법이 촉각뿐이다. navigator.vibrate는 iOS Safari에서 지원되지 않으므로, 시각 피드백(선택 테두리 애니메이션)을 함께 넣어야 한다.
6. 리사이즈 핸들 — 손가락 크기를 전제로
데스크톱의 8px 코너 핸들을 그대로 옮기면 모바일에서 잡을 수 없다.
[핸들 설계]
시각 크기 12~16px (작아 보여도 됨)
히트 영역 최소 44×44px ← 실제로 중요한 값
개수 모바일은 4개(코너)면 충분. 8개는 과밀
선택 시에만 항상 표시하면 캔버스가 지저분해짐
.resize-handle {
width: 14px; height: 14px;
position: absolute;
}
.resize-handle::after { /* 히트 영역 확장 */
content: '';
position: absolute;
inset: -15px; /* 14 + 30 = 44px */
}
기준은 최소 타겟 크기에 24×24로 규정돼 있지만, 드래그 조작에는 44px 쪽이 실사용에 맞는다. 탭은 24px로 되지만 끌기는 다르다.
요소가 작을 때의 처리도 정해야 한다. 40px짜리 카드에 44px 핸들 4개를 붙이면 카드가 핸들에 묻힌다.
요소가 핸들 3개 폭보다 작으면
→ 핸들을 요소 바깥으로 배치
→ 또는 우하단 하나만 표시
7. 실행 취소 — 모바일에서는 필수다
데스크톱보다 오조작이 훨씬 잦으므로, 실행 취소가 없으면 사용자가 편집을 두려워한다.
type Command = {
label: string;
do: () => void;
undo: () => void;
};
const undoStack: Command[] = [];
const redoStack: Command[] = [];
function execute(cmd: Command) {
cmd.do();
undoStack.push(cmd);
redoStack.length = 0;
if (undoStack.length > 50) undoStack.shift();
}
드래그를 커맨드 하나로 묶는 것이 중요하다. pointermove마다 커맨드를 쌓으면 실행 취소 한 번에 1픽셀씩 돌아간다.
pointerdown → 시작 위치 기록
pointermove → 화면만 갱신 (스택에 안 쌓음)
pointerup → { 시작위치 → 최종위치 } 커맨드 1개 push
UI 배치도 데스크톱과 다르다. 단축키가 없으므로 실행 취소 버튼이 화면에 있어야 하고, 엄지가 닿는 하단이 적합하다. 상단 툴바에 두면 한 손 조작에서 닿지 않는다.
8. 저장 — 편집 중 이탈에 대비
모바일은 앱 전환·전화 수신으로 언제든 화면을 벗어난다.
const persist = () => saveDraft(serialize(state));
document.addEventListener('visibilitychange', () => {
if (document.hidden) persist();
});
window.addEventListener('pagehide', persist);
beforeunload는 모바일 브라우저에서 신뢰할 수 없으므로 쓰지 않는다. Page Visibility API의 visibilitychange와 pagehide 조합이 맞다.
캔버스 편집을 그대로 공개 페이지로 내보내는 제품이라면, 편집 중 초안과 공개본을 분리해야 한다. Linkme의 쇼케이스에 올라온 페이지들처럼 완성본이 외부에 노출되는 구조에서는, 저장 자동화가 곧 공개는 아니어야 한다 — 편집 중 상태가 실시간으로 공개되면 사용자가 마음 놓고 만들 수 없다.
자동 저장 → 초안(draft)에만
공개 → 명시적 액션으로만
9. 정리
1. 제스처는 시작 지점 + 손가락 수로 구분 (모드 전환은 최후)
2. touch-action: none + -webkit-touch-callout: none (캔버스 영역만)
3. 포인터를 Map으로 추적, 2번째 손가락에서 드래그 취소(원위치)
4. pointercancel 처리 필수
5. 핀치는 두 손가락 중점 고정 + 배율 상하한 + "화면에 맞추기"
6. 롱프레스는 시간 450ms + 이동 10px 이내, 진동/시각 피드백
7. 리사이즈 핸들 히트 영역 44px, 작은 요소는 바깥 배치
8. 드래그 = 커맨드 1개, 실행 취소는 하단에
9. 자동 저장은 초안까지, 공개는 명시적으로
한 가지만 꼽으면 **3번의 "두 번째 손가락에서 드래그 취소"**다. 이게 빠지면 핀치 줌을 할 때마다 요소가 딸려 움직이고, 사용자는 편집기를 신뢰하지 않게 된다.