fix(study): pointercancel 폐기 + multi-touch race 차단 + iOS palm rejection 회복

진단 (사용자 디버그 패널): up:3 cancel:4 — pointerup 보다 cancel 이 더 많음.
iPad OS 가 multi-touch / 시스템 gesture 인식 시 active pen pointer 를
강제 cancel. cancel 된 stroke 가 strokes 에 들어가면서 의도 아닌 짧은
노이즈 stroke 누적 → 사용자 글자 망가짐.

[Fix 1] pointercancel 시 inflight 폐기:
- 기존: cancel 도 endStroke 에서 inflight.points.length >= 1 면 strokes 에 추가
- 변경: cancel 은 inflight = null 로 폐기, scheduleRedraw 만
- pointerup 만 정상 finalize

[Fix 2] isDrawing 중 새 pointerdown 무시:
- multi-touch / 두번째 pen 시도 시 진행 stroke 보호
- onPointerDown 첫줄에 if (isDrawing) return

[Fix 3] document level touchstart/touchmove preventDefault 제거:
- blockMultiTouch 가 touch 이벤트 default 처리 차단 → iOS Safari 자체
  palm rejection 메커니즘 망가뜨려 pointercancel 발생률 증가시킴
- 캔버스의 touch-action: none + 영역 외 일반 동작 허용으로 변경
- 핀치줌 차단은 wheel+ctrlKey (데스크톱) + gesture 이벤트 (iOS) 만 유지
This commit is contained in:
Hyungi Ahn
2026-04-27 11:28:38 +09:00
parent 743b1b1b6a
commit f88524495a
2 changed files with 23 additions and 19 deletions
@@ -262,18 +262,15 @@
dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1 };
return;
}
// 이미 다른 pointer 가 그리는 중이면 무시 — palm rejection / multi-touch race 차단.
// (Apple Pencil 진행 중에 손가락 닿거나 두 번째 pen 시도하면 둘 다 망가짐)
if (isDrawing) return;
e.preventDefault();
try { document.getSelection?.()?.removeAllRanges?.(); } catch {}
// 이전 inflight 가 어떤 이유로 살아있다면 즉시 finalize (다음 stroke 누락 방지).
if (inflight) {
if (inflight.points.length > 1) {
strokes = [...strokes, inflight];
isDirty = true;
backup();
}
inflight = null;
}
// 이전 inflight 가 살아있다면 폐기 (다음 stroke 와 섞이지 않게).
inflight = null;
try { canvas.setPointerCapture(e.pointerId); } catch {}
activePointerId = e.pointerId;
@@ -339,8 +336,10 @@
scheduleRedraw();
}
// pointerup / pointercancel 만 finalize. pointerleave 는 무시 — stale leave 가
// 진행 중 stroke 를 강제 종료시켜 다음 pointermove 가 다 누락되는 핵심 버그 차단.
// pointerup → 정상 finalize. pointercancel → inflight 폐기 (사용자 의도가 아닌
// OS 강제 취소 — multi-touch / 시스템 gesture 인식 시 발생. cancel 된 stroke 가
// strokes 에 들어가면 의도치 않은 짧은 노이즈 stroke 누적, 사용자 글자 망가짐).
// pointerleave 는 무시 — stale leave 가 진행 중 stroke 끊는 케이스 방어.
function endStroke(e: PointerEvent) {
if (e.type === 'pointerup') dbg = { ...dbg, up: dbg.up + 1 };
else if (e.type === 'pointercancel') dbg = { ...dbg, cancel: dbg.cancel + 1 };
@@ -354,13 +353,21 @@
activePointerId = null;
isDrawing = false;
const wasCanceled = e.type === 'pointercancel';
if (tool === 'eraser') {
inflight = null;
return;
}
// 1점 stroke (짧은 탭) 도 보존 — 사용자가 그린 모든 획이 들어와야 함.
// pointercancel 은 stroke 폐기.
if (wasCanceled) {
inflight = null;
scheduleRedraw();
return;
}
// pointerup: 정상 종료 — 1점 stroke (짧은 탭) 도 보존.
if (inflight && inflight.points.length >= 1) {
strokes = [...strokes, inflight];
undoStack = [];
@@ -42,13 +42,14 @@
function blockGesture(e) { e.preventDefault(); }
// 추가: touchstart 가 두 손가락이면 preventDefault — 일부 Safari 빌드에서
// gesture 이벤트 미발화 시에도 핀치 제스처 자체를 차단.
function blockMultiTouch(e) {
if (e.touches && e.touches.length > 1) e.preventDefault();
}
// 데스크톱 Chrome/Firefox: trackpad 핀치 = wheel + ctrlKey 로 디스패치.
function blockPinchWheel(e) {
if (e.ctrlKey || e.metaKey) e.preventDefault();
}
// 주의: blockMultiTouch (touchstart 의 length>1 preventDefault) 는 제거함.
// iOS Safari 의 자체 palm rejection (Apple Pencil 닿으면 손가락 touch 자동 무시) 이
// touch 이벤트 default 처리에 의존하는데, preventDefault 가 그 메커니즘을 망가뜨려
// pointercancel 발생률 증가시킴 (사용자 디버그 패널에서 cancel:4 관측).
// 모든 button 공통 inline style — 더블탭 줌 / long-press 메뉴 / 텍스트 선택 차단.
const BTN_STYLE =
@@ -69,8 +70,6 @@
document.addEventListener('gesturestart', blockGesture, { passive: false });
document.addEventListener('gesturechange', blockGesture, { passive: false });
document.addEventListener('gestureend', blockGesture, { passive: false });
document.addEventListener('touchstart', blockMultiTouch, { passive: false });
document.addEventListener('touchmove', blockMultiTouch, { passive: false });
document.addEventListener('wheel', blockPinchWheel, { passive: false });
});
onDestroy(() => {
@@ -78,8 +77,6 @@
document.removeEventListener('gesturestart', blockGesture);
document.removeEventListener('gesturechange', blockGesture);
document.removeEventListener('gestureend', blockGesture);
document.removeEventListener('touchstart', blockMultiTouch);
document.removeEventListener('touchmove', blockMultiTouch);
document.removeEventListener('wheel', blockPinchWheel);
}
});