PixelForge에 모바일/태블릿 풀 편집 지원 추가
데스크톱 전용이던 픽셀 아트 에디터에 폰/태블릿 풀 편집 지원을 추가했습니다. 핀치 줌, 두 손가락 팬, 시스템 공유, 모바일 협업까지 4개 커밋으로 묶어 디자인 문서부터 배포까지 한 세션에 끝낸 과정을 공유합니다.
데스크톱 전용이던 픽셀 아트 에디터를 폰과 태블릿에서도 풀 편집 가능하게 만들었습니다. · pixel-forge.gunggme.cc

한 줄 소개
PixelForge는 원래 1024px 미만 뷰포트에서 "Viewport Too Small" 차단 화면을 띄우던 데스크톱 전용 도구였습니다. 이번 작업으로 같은 코드베이스 안에서 폰(<640px)은 별도 모바일 셸로, 태블릿(640–1023px)은 압축된 데스크톱 레이아웃으로 라우팅합니다. 핀치 줌, 두 손가락 팬, 시스템 공유, 모바일 협업까지 4개 커밋으로 이어붙였습니다.
주요 기능
- 📱 폰 전용 레이아웃 — 캔버스 65% 확보, 상단 AppBar(undo/redo/오버플로우 메뉴), 가로 스크롤 ToolBar, 하단 탭바(Color/Layers/Frames)
- 📄 BottomSheet 프리미티브 — 드래그로 닫기, 백드롭, focus trap, safe-area-inset 적용한 재사용 가능한 시트
- 🤏 멀티터치 제스처 — 핀치 줌(1.18 ratio threshold), 두 손가락 팬, 두 번째 손가락 닿으면 진행 중이던 그리기 자동 중단
- 📐 태블릿 적응 — 사이드바 200px로 캡, 타임라인 기본 접힘, 툴바 56px(터치 친화)
- 🔗 시스템 공유 —
navigator.share지원 시 OS 공유 시트, 미지원 시 기존 ShareDialog 폴백 - 🔌 협업 끊김 알림 — 인증 실패/연결 끊김 시 sticky 배너 (데스크톱+모바일 공통)
- 🎨 터치 친화 ColorPickerDialog — MouseEvent → PointerEvent 마이그레이션,
setPointerCapture로 캔버스 밖 드래그 추적 - 🏗 데스크톱 회귀 0건 — 기존 528개 테스트(useViewport+EditorRouter 추가 후 522개) 전부 통과
화면 소개
폰 — 새 스프라이트 다이얼로그

처음 진입 시 데스크톱과 동일한 New Sprite 모달이 폰 화면에 맞게 렌더됩니다. 1024px 차단 화면이 사라지고 폰에서도 바로 시작 가능합니다.
폰 — 메인 편집

캔버스가 화면의 약 65%를 차지하고, 상단에 AppBar, 그 아래 ToolOptions, 하단에 ToolBar(가로 스크롤)와 BottomTabBar 순으로 쌓입니다. 우측 하단 status chip은 좌표·줌 비율을 컴팩트하게 표시합니다.
폰 — 색상 & 팔레트 시트

하단 탭바의 🎨 Color 누르면 RGB/HSV/HSL 슬라이더, hex 입력, DB-16 팔레트, foreground/background 스왑까지 한 시트에 펼쳐집니다.
폰 — 레이어 시트

블렌드 모드 + opacity 슬라이더 + 레이어 리스트 + 레이어 추가/삭제/병합/이동 버튼 묶음. 데스크톱 LayerPanel을 그대로 시트에 임베드 — 코드 재사용.
폰 — 프레임 & 애니메이션 시트

Timeline / Sheet / Preview 3탭, 프레임 편집 버튼, FPS 입력, 어니언 스킨 토글까지 전부 폰에서 동작.
폰 — 오버플로우 메뉴 (⋯)

데스크톱 MenuBar(File/Edit/Select/Sprite/Layer/Frame/View/Help — 8개)를 핵심 액션 5개(New, Export, Share, Preferences, Help)로 압축. 폰에서 자주 쓰는 항목만 남김.
태블릿 — 압축된 데스크톱 레이아웃

태블릿 뷰포트(640–1023px)에서는 데스크톱 그리드를 유지하되: 사이드바 200px로 캡, 타임라인 기본 접힘, 툴바 컬럼 56px로 확장. 별도 셸을 만들지 않아도 터치 환경에서 자연스럽게 동작.
기술 스택
| 영역 | 기술 |
|---|---|
| 뷰포트 분기 | matchMedia 기반 useViewport() 훅 (SSR 안전) |
| 멀티터치 | Pointer Events + setPointerCapture |
| 시트 | 자체 구현 BottomSheet (드래그 hit threshold 80px) |
| 시스템 공유 | navigator.share (Web Share API) |
| 안전 영역 | env(safe-area-inset-*) CSS |
| 테스트 | Vitest + matchMedia stub + EditorRouter render test |
아키텍처 — 라우팅 분기
App.tsx
└── Route /editor → EditorRouter
│
├── viewport === 'phone' → MobileEditorLayout
│ ├── MobileAppBar
│ ├── CanvasArea
│ ├── ToolOptionsBar
│ ├── MobileToolBar
│ ├── MobileBottomTabBar
│ └── BottomSheet × 3 (Color/Layers/Frames)
│
└── viewport === 'tablet' or 'desktop'
→ EditorLayout (viewport-aware grid)
├── 'tablet' → 사이드바 200px max,
│ 타임라인 접힘, 툴바 56px
└── 'desktop' → 현행 그대로
핵심 원칙: 데스크톱 레이아웃은 1픽셀도 바꾸지 않는다. 폰은 별도 셸, 태블릿은 같은 그리드의 viewport-aware 변형.
개발 방법론
디자인 우선
코드를 짜기 전 docs/mobile-editor-plan.md에 6단계(M1–M6) 롤아웃을 적었습니다. 폰 가로/세로 ASCII 와이어프레임, 제스처 매핑 표, 구현 순서, 열린 질문, Phase 종료 검증 기준까지. 이 문서가 결과적으로 4개 커밋의 가이드 역할을 했습니다.
Anti-over-engineering 적용
CLAUDE.md의 7가지 원칙(60초 룰, YAGNI, 최소 시작, 한 도구씩, 인터페이스는 필요할 때만, 조기 추상화 금지, 측정 후 최적화)을 곳곳에 적용:
- 별도
useCanvasGestures훅 만들지 않고 기존useCanvas에 멀티터치 로직을 합쳤습니다. 두 훅이 똑같은 renderer ref를 공유해야 하므로 분리는 조기 추상화. MOBILE_PINNED_TOOLS같은 상수도toolList.ts한 파일에 평면적으로 둠. 별도 모듈 만들지 않음.- Long-press 스포이드는 첫 픽셀 commit과 충돌하는 미묘한 UX라 M3에서 의도적으로 보류 — 실기기 테스트 후 결정으로 미뤘습니다.
TDD는 도구
useViewport 훅과 EditorRouter 컴포넌트는 단위 테스트부터 작성. 단, 시각적 컴포넌트(MobileAppBar, BottomSheet)는 E2E/실기기 검증 영역이라 강제 TDD를 적용하지 않았습니다.
기술적 도전 & 해결
1. 핀치 줌 도중 의도치 않은 드로잉
폰에서 두 손가락으로 줌하려 했는데 첫 번째 손가락이 그리기 stroke를 시작해버리는 문제. 해결:
if (e.pointerType === 'touch') {
activePointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (activePointers.current.size >= 2) {
gestureActive.current = true;
isDrawing.current = false; // 드로잉 즉시 중단
// ...baseline distance/midpoint 기록
}
}
두 번째 포인터가 닿는 순간 isDrawing 플래그를 false로 강제 해제. 추가로 pointerup 시에도 gesture 모드면 tool finalize 호출을 건너뜀.
2. 1024px 하드 차단 → 미디어 쿼리 게이팅
기존 EditorLayout.module.css에 min-width: 1024px가 박혀 있어 태블릿/폰에서 가로 스크롤이 발생. 단순히 제거하면 데스크톱에서도 작아질 수 있음. 해결:
.layout {
min-height: 480px; /* 항상 적용 */
}
@media (min-width: 1024px) {
.layout {
min-width: 1024px; /* 데스크톱 한정 */
}
}
미디어 쿼리로 데스크톱 한정 가드. 같은 그리드 정의가 폰/태블릿/데스크톱 모두에서 살아있음.
3. ColorPicker가 터치에서 동작 안 함
색상 휠/슬라이더가 onMouseDown/onMouseMove로 짜여 있어 터치에서 무반응. 해결:
const handleSVPointer = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
if (e.buttons !== 1) return;
if (e.type === 'pointerdown') e.currentTarget.setPointerCapture(e.pointerId);
// ...
}, [hsv.h, alpha, updateColor]);
React.MouseEvent → React.PointerEvent로 통합, setPointerCapture로 캔버스 밖으로 끌어내도 추적 유지, CSS touch-action: none 추가로 브라우저 기본 제스처 차단.
4. Sheet 백드롭이 BottomTabBar 클릭 가로채는 이슈
스크린샷 작업 중 발견: BottomSheet가 열려있을 때 다른 탭(예: Color → Layers)을 누르면 backdrop(z-index 900)이 클릭을 흡수해서 sheet 변경이 안 됨. 사용자는 X 버튼이나 backdrop 탭으로 닫고 다시 열어야 함. 현재는 의도된 모달 동작이지만 모바일 UX 관점에서는 시트 간 직접 전환이 자연스러움. M6 실기기 QA 후 BottomTabBar의 z-index를 backdrop보다 높게 올리는 보정을 검토 예정.
숫자로 보는
| 항목 | 값 |
|---|---|
| 작업 기간 | 1세션 (계획 + 구현 + 4커밋) |
| 신규 파일 | 12개 (useViewport, EditorRouter, MobileEditorLayout, MobileAppBar, MobileToolBar, MobileBottomTabBar, BottomSheet, CollabBanner, toolList, mobile plan doc) |
| 수정 파일 | 6개 (App.tsx, EditorLayout.tsx/css, ColorPickerDialog.tsx/css, useCanvas.ts, editorStore.ts, ShareDialog.tsx, ToolBar.tsx/css) |
| 커밋 | 4개 (foundation / phone shell / gestures / tablet+share+banner) |
| 테스트 | 522개 통과 (기존 519 + 신규 useViewport 5 + EditorRouter 3, 회귀 0) |
| 빌드 | Vite 205 modules, 502KB → gzip 150KB |
| 디자인 문서 | docs/mobile-editor-plan.md (M1–M6 6단계, 와이어프레임 + 제스처 표) |
배운 것
- 종이 위에서 끝낸 설계가 코드 작성을 빠르게 한다. ASCII 와이어프레임과 제스처 매핑 표를 미리 그리니 4개 커밋이 막힘없이 이어졌습니다. 디자인 문서가 PR 설명을 자동으로 만들어준 셈.
- Pointer Events는 마우스+터치+펜의 통합 인터페이스.
setPointerCapture하나로 마우스 드래그도, 터치 슬라이드도, 펜 입력도 같은 코드 경로. ColorPicker와 캔버스 둘 다 이 패턴으로 통일. - viewport-aware는 별도 셸 vs 같은 그리드 변형 두 갈래. 폰은 데스크톱과 멘탈 모델이 너무 달라서 별도 컴포넌트, 태블릿은 그리드 파라미터만 조정. 1픽셀도 데스크톱 회귀 없이.
- 모달 시트의 backdrop은 양날의 검. 포커스를 가두는 데는 좋지만 같은 카테고리의 다른 시트 사이 전환을 막음. 모바일 네이티브 앱들이 BottomTabBar를 항상 위에 두는 이유를 직접 부딪혀 알게 됨.
링크
- 라이브 데모: pixel-forge.gunggme.cc
- GitHub: @gunggme/pixel-forge
- 작성자: 김주영 (@gunggme)