diff --git a/frontend/src/lib/components/HandwriteCanvas.svelte b/frontend/src/lib/components/HandwriteCanvas.svelte index a471798..0a64008 100644 --- a/frontend/src/lib/components/HandwriteCanvas.svelte +++ b/frontend/src/lib/components/HandwriteCanvas.svelte @@ -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'};" > - -