fix(study): HandwriteCanvas Phase 1 polish — 디버그 UI DEV 게이트 + pointerleave 정리 + 지우개 segment 거리

- 라이브 디버그 패널 / build timestamp 를 import.meta.env.DEV 로 게이트.
  prod 번들에서 Vite 가 dead-code-eliminate.
- onpointerleave={endStroke} 바인딩 제거. setPointerCapture 가 잡히면 leave 자체가
  안 오고, 캡처 실패 케이스는 OS 가 pointercancel 로 흘려보냄. 주석과 동작 일치.
- eraseAt(x,y) 단일 점 검사 → eraseSegment(x0,y0,x1,y1) 로 교체.
  distSqPointToSegment 헬퍼 추가. eraserLast 추적 (pointerdown set, move 의 segment
  시작점, end 에서 null). 빠른 지우개 stroke 에서 점 사이 stroke 누락 방지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-27 12:03:34 +09:00
parent f88524495a
commit 33d4fd39c4
2 changed files with 56 additions and 17 deletions
@@ -77,6 +77,10 @@
let effectiveSize = $derived(baseSize * WIDTH_FACTOR[widthMode]);
let eraserRadius = $derived(Math.max(16, effectiveSize * 4));
// 지우개 이동 경로의 직전 점. pointerdown 에서 set, pointermove 에서 segment
// 시작점, end 에서 null. $state 아님 — 입력 루프 내부 값 (UI 미참조).
let eraserLast: [number, number] | null = null;
let isDirty = false;
let saveTimer: number | null = null;
let snapshotting = $state(false);
@@ -235,7 +239,33 @@
target.points.push([x, y, p]);
}
function eraseAt(x: number, y: number): boolean {
// 점 P 와 선분 [A, B] 사이 최단 거리의 제곱. 빠른 비교를 위해 sqrt 안 씀.
function distSqPointToSegment(
px: number, py: number,
ax: number, ay: number, bx: number, by: number,
): number {
const dx = bx - ax;
const dy = by - ay;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) {
const ex = px - ax;
const ey = py - ay;
return ex * ex + ey * ey;
}
let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
if (t < 0) t = 0;
else if (t > 1) t = 1;
const ex = px - (ax + t * dx);
const ey = py - (ay + t * dy);
return ex * ex + ey * ey;
}
// 지우개 이동 경로 [A, B] 위에 stroke 점이 eraserRadius 이내로 들어오면 해당
// stroke 통째 삭제 (object eraser). 단일 점 검사가 아니라 segment 검사라 빠른
// 지우개 이동에서 점 사이 stroke 누락 방지. A == B 면 단일 점 검사로 환원.
function eraseSegment(
x0: number, y0: number, x1: number, y1: number,
): boolean {
if (strokes.length === 0) return false;
const r2 = eraserRadius * eraserRadius;
const removed: Stroke[] = [];
@@ -243,9 +273,10 @@
for (const s of strokes) {
let hit = false;
for (const [px, py] of s.points) {
const dx = px - x;
const dy = py - y;
if (dx * dx + dy * dy <= r2) { hit = true; break; }
if (distSqPointToSegment(px, py, x0, y0, x1, y1) <= r2) {
hit = true;
break;
}
}
(hit ? removed : keep).push(s);
}
@@ -279,7 +310,8 @@
const [x, y] = getLocalXY(e);
if (tool === 'eraser') {
if (eraseAt(x, y)) {
eraserLast = [x, y];
if (eraseSegment(x, y, x, y)) {
isDirty = true;
backup();
scheduleSave();
@@ -310,12 +342,14 @@
const [x, y] = getLocalXY(e);
if (tool === 'eraser') {
if (eraseAt(x, y)) {
const prev = eraserLast ?? [x, y];
if (eraseSegment(prev[0], prev[1], x, y)) {
isDirty = true;
backup();
scheduleSave();
scheduleRedraw();
}
eraserLast = [x, y];
return;
}
@@ -339,7 +373,8 @@
// pointerup → 정상 finalize. pointercancel → inflight 폐기 (사용자 의도가 아닌
// OS 강제 취소 — multi-touch / 시스템 gesture 인식 시 발생. cancel 된 stroke 가
// strokes 에 들어가면 의도치 않은 짧은 노이즈 stroke 누적, 사용자 글자 망가짐).
// pointerleave 는 무시 — stale leave 가 진행 중 stroke 끊는 케이스 방어.
// pointerleave 는 핸들러 미바인딩 — setPointerCapture 가 잡히면 leave 자체가 안
// 오고, 캡처 실패 케이스는 OS 가 pointercancel 로 흘려보냄.
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 };
@@ -357,6 +392,7 @@
if (tool === 'eraser') {
inflight = null;
eraserLast = null;
return;
}
@@ -584,20 +620,21 @@
onpointermove={onPointerMove}
onpointerup={endStroke}
onpointercancel={endStroke}
onpointerleave={endStroke}
oncontextmenu={(e) => e.preventDefault()}
onselectstart={(e) => e.preventDefault()}
class="block"
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>
<!-- 라이브 디버그 패널 — 사용자가 어떤 이벤트가 들어오고 어디서 거부되는지 직접 확인 -->
<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/>
rejType:{dbg.rejectedByType} rejId:{dbg.rejectedByPointerId} coal:{dbg.coalesced}<br/>
drawing:{isDrawing ? 'Y' : 'N'} actId:{activePointerId ?? '-'} infPts:{dbgInflightPts}<br/>
strokes:{strokes.length}
</div>
{#if import.meta.env.DEV}
<!-- 라이브 디버그 패널 — DEV 빌드 한정. prod 에선 Vite 가 dead-code-eliminate. -->
<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/>
rejType:{dbg.rejectedByType} rejId:{dbg.rejectedByPointerId} coal:{dbg.coalesced}<br/>
drawing:{isDrawing ? 'Y' : 'N'} actId:{activePointerId ?? '-'} infPts:{dbgInflightPts}<br/>
strokes:{strokes.length}
</div>
{/if}
</div>
</div>
@@ -189,7 +189,9 @@
{sess.study_type === 'language' ? `${sess.language_code || '?'} · ${sess.learning_level || ''}` : (sess.certification || '자격증')}
· {sess.subject || ''} · {sess.topic || ''}
{/if}
<span class="ml-2 text-[10px] text-dim/60">build {__BUILD_TIME__}</span>
{#if import.meta.env.DEV}
<span class="ml-2 text-[10px] text-dim/60">build {__BUILD_TIME__}</span>
{/if}
</div>
<button