fix(study): Apple Pencil hover (buttons===0) stroke 연장 차단 + ?debug=1 toggle

스크린샷 진단: 사용자 시나리오에서 stroke 자체는 들어가지만 글씨가 흩어지고 ㄱ→ㅏ 가
의도와 다르게 연결됨. 코드 재검토 결과 명백한 누락 — pointermove 가 e.buttons===0
케이스 (Apple Pencil hover, iPadOS 17+) 를 잡지 않아 hover 이동이 stroke 의 점으로
추가됨. ㄱ 그리고 → 펜 살짝 떼고 (hover 모드, pointerup 안 옴) → ㅏ 위치로 hover
이동 → hover pointermove 가 점 push → ㄱ 끝점에서 ㅏ 위치까지 직선/엉킴.

Fix:
- onPointerMove 에서 e.pointerType==='pen' && e.buttons===0 감지 시 stroke 즉시
  finalize: capture release + isDrawing=false + inflight 보존 (pointerup 흐름).
  pointerup 안 와도 hover 모드 = 사실상 펜 떼짐. 다음 stroke 진입 보장.
- onPointerDown 에서도 같은 가드 (hover-down reject) — hover 진입을 stroke 시작으로
  오인 차단.

Diagnostic:
- DBG = import.meta.env.DEV || (?debug=1 query). prod 에서도 사용자 iPad 진단용으로
  디버그 패널 토글 가능. URL 에 ?debug=1 추가 후 reload.
- 디버그 패널 {#if DBG} 로 게이트.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-27 12:54:42 +09:00
parent 8f1c7175d4
commit 50e0a78e1a
@@ -63,9 +63,12 @@
let isDrawing = $state(false);
let activePointerId = $state<number | null>(null);
// ── 디버그 카운터 — DEV 빌드 한정. prod 에선 mutation 자체가 DCE 되도록 모든
// dbg = ... 호출을 if (DBG) 로 감쌌음.
const DBG = import.meta.env.DEV;
// ── 디버그 카운터 — DEV 빌드 또는 prod 에서 ?debug=1 query 시 활성화.
// prod 에서 mutation 자체를 DCE 하려면 import.meta.env.DEV 단독이지만,
// 사용자 iPad 진단을 위해 prod 에서도 query 로 토글 가능하게 함. const 라
// 페이지 로드 시 한 번만 평가 (성능 영향 미미).
const DBG = import.meta.env.DEV ||
(typeof window !== 'undefined' && /[?&]debug=1\b/.test(window.location.search));
let dbg = $state({
down: 0, move: 0, up: 0, cancel: 0, leave: 0,
rejectedByType: 0, rejectedByPointerId: 0,
@@ -297,6 +300,13 @@
if (DBG) dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1 };
return;
}
// pen 의 hover-down (buttons===0) 은 무시 — 실제 접촉이 아닌 hover 진입.
// Pencil 닿는 순간 buttons===1. 이걸 stroke 시작으로 오인하면 hover 이동이
// 점으로 추가됨.
if (e.pointerType === 'pen' && e.buttons === 0) {
if (DBG) dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1, lastType: 'hover-down' };
return;
}
// 이미 다른 pointer 가 그리는 중이면 무시 — palm rejection / multi-touch race 차단.
// (Apple Pencil 진행 중에 손가락 닿거나 두 번째 pen 시도하면 둘 다 망가짐)
if (isDrawing) return;
@@ -343,6 +353,35 @@
return;
}
// ── Apple Pencil hover 감지 (iPadOS 17+) ─────────────────────────
// 펜이 화면에서 떨어진 채로도 pointermove 가 발화 — pointerType==='pen' 이지만
// buttons===0 (펜 안 닿음). pointerup 안 와도 hover 모드는 사실상 stroke 종료.
// 이 케이스를 잡지 않으면 hover 이동이 stroke 의 점으로 추가됨 → ㄱ 다음에
// ㅏ 위치로 hover 이동 시 ㄱ 끝점에서 ㅏ 위치까지 직선이 그어지거나 한 stroke
// 가 의도치 않게 연장됨 (사용자 보고: ㄱ→ㅏ 가 안 써짐, 글씨 흩어짐).
if (e.pointerType === 'pen' && e.buttons === 0) {
try { canvas?.releasePointerCapture?.(e.pointerId); } catch {}
activePointerId = null;
isDrawing = false;
if (DBG) dbg = { ...dbg, up: dbg.up + 1, lastType: 'hover-end' };
if (tool === 'eraser') {
inflight = null;
eraserLast = null;
return;
}
// pointerup 흐름: 1점 stroke (짧은 탭) 도 보존.
if (inflight && inflight.points.length >= 1) {
strokes = [...strokes, inflight];
undoStack = [];
isDirty = true;
backup();
scheduleSave();
}
inflight = null;
scheduleRedraw();
return;
}
const [x, y] = getLocalXY(e);
if (tool === 'eraser') {
@@ -653,8 +692,8 @@
style="touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; cursor: {tool === 'eraser' ? 'cell' : 'crosshair'};"
></canvas>
{#if import.meta.env.DEV}
<!-- 라이브 디버그 패널 — DEV 빌드 한정. prod 에선 Vite 가 dead-code-eliminate. -->
{#if DBG}
<!-- 라이브 디버그 패널 — DEV 빌드 또는 prod 에서 ?debug=1 query 시 활성. -->
<div class="absolute top-1 left-1 px-2 py-1 rounded bg-bg/90 text-[10px] text-dim font-mono pointer-events-none leading-tight">
type:{dbg.lastType} p:{dbg.lastPressure.toFixed(2)}<br/>
down:{dbg.down} move:{dbg.move} up:{dbg.up} cancel:{dbg.cancel}<br/>