From f88524495a784521664dec33e26bca020767bceb Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 27 Apr 2026 11:28:38 +0900 Subject: [PATCH] =?UTF-8?q?fix(study):=20pointercancel=20=ED=8F=90?= =?UTF-8?q?=EA=B8=B0=20+=20multi-touch=20race=20=EC=B0=A8=EB=8B=A8=20+=20i?= =?UTF-8?q?OS=20palm=20rejection=20=ED=9A=8C=EB=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 진단 (사용자 디버그 패널): 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) 만 유지 --- .../src/lib/components/HandwriteCanvas.svelte | 31 ++++++++++++------- .../src/routes/study/write/[id]/+page.svelte | 11 +++---- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/components/HandwriteCanvas.svelte b/frontend/src/lib/components/HandwriteCanvas.svelte index 93d22d8..a471798 100644 --- a/frontend/src/lib/components/HandwriteCanvas.svelte +++ b/frontend/src/lib/components/HandwriteCanvas.svelte @@ -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 = []; diff --git a/frontend/src/routes/study/write/[id]/+page.svelte b/frontend/src/routes/study/write/[id]/+page.svelte index 40538fd..f64552b 100644 --- a/frontend/src/routes/study/write/[id]/+page.svelte +++ b/frontend/src/routes/study/write/[id]/+page.svelte @@ -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); } });