diff --git a/frontend/src/lib/components/HandwriteCanvas.svelte b/frontend/src/lib/components/HandwriteCanvas.svelte index 658ba16..9ecc1df 100644 --- a/frontend/src/lib/components/HandwriteCanvas.svelte +++ b/frontend/src/lib/components/HandwriteCanvas.svelte @@ -52,14 +52,20 @@ let strokes = $state(initialStrokes?.strokes ?? []); let undoStack = $state([]); - let inflight: Stroke | null = $state(null); + // inflight 는 plain 변수 — Svelte 5 deep proxy 가 매 pointermove 의 coalesced + // push 마다 reactive notify 하면서 onPointerMove 핸들러 실행 시간 누적 → 다음 + // pointermove 가 native queue 에 적체 → 빠른 stroke 시 capture race 가능성. + // UI 는 redraw 함수가 호출 시점에 inflight 를 직접 읽으므로 reactive 불필요. + let inflight: Stroke | null = null; - // 입력 상태머신 — 다음 stroke 가 막히지 않게 명확히 관리. - // $state 로 — 디버그 패널에 표시하기 위해. closure 동작은 동일. + // 입력 상태머신 — 다음 stroke 가 막히지 않게 명확히 관리. $state 유지 (디버그 + // 패널 표시 + 새 stroke 진입 가드 readability). let isDrawing = $state(false); let activePointerId = $state(null); - // ── 디버그 카운터 — 사용자가 어디서 누락 발생하는지 직접 보도록 페이지에 라이브 표시. + // ── 디버그 카운터 — DEV 빌드 한정. prod 에선 mutation 자체가 DCE 되도록 모든 + // dbg = ... 호출을 if (DBG) 로 감쌌음. + const DBG = import.meta.env.DEV; let dbg = $state({ down: 0, move: 0, up: 0, cancel: 0, leave: 0, rejectedByType: 0, rejectedByPointerId: 0, @@ -67,8 +73,6 @@ lastType: '-', lastPressure: 0, }); - // 마지막 stroke 의 점 개수 (inflight 또는 직전 finalize 한 stroke) - let dbgInflightPts = $derived(inflight?.points.length ?? 0); let strokeColor = $state('#e4e4e7'); let tool = $state('pen'); @@ -287,10 +291,10 @@ } function onPointerDown(e: PointerEvent) { - dbg = { ...dbg, down: dbg.down + 1, lastType: e.pointerType, lastPressure: e.pressure }; + if (DBG) dbg = { ...dbg, down: dbg.down + 1, lastType: e.pointerType, lastPressure: e.pressure }; if (!canvas) return; if (!isPenLike(e)) { - dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1 }; + if (DBG) dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1 }; return; } // 이미 다른 pointer 가 그리는 중이면 무시 — palm rejection / multi-touch race 차단. @@ -328,14 +332,14 @@ } function onPointerMove(e: PointerEvent) { - dbg = { ...dbg, move: dbg.move + 1, lastType: e.pointerType, lastPressure: e.pressure }; + if (DBG) dbg = { ...dbg, move: dbg.move + 1, lastType: e.pointerType, lastPressure: e.pressure }; if (!isDrawing) return; if (!isPenLike(e)) { - dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1 }; + if (DBG) dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1 }; return; } if (e.pointerId !== activePointerId) { - dbg = { ...dbg, rejectedByPointerId: dbg.rejectedByPointerId + 1 }; + if (DBG) dbg = { ...dbg, rejectedByPointerId: dbg.rejectedByPointerId + 1 }; return; } @@ -358,7 +362,7 @@ // coalesced events: 빠른 필기에서 보간 점들 모두 반영. 추가로 점 사이 거리가 // 8px 초과 시 자동 중간 점 보간 → 점선 방지. const coalesced = (e.getCoalescedEvents?.() ?? []) as PointerEvent[]; - dbg = { ...dbg, coalesced: dbg.coalesced + coalesced.length }; + if (DBG) dbg = { ...dbg, coalesced: dbg.coalesced + coalesced.length }; if (coalesced.length > 0) { for (const ev of coalesced) { const [cx, cy] = getLocalXY(ev); @@ -378,17 +382,17 @@ // pointerup 이 캔버스에 routing 안 된 케이스 → 이 분기에서 강제 finalize 해야 // isDrawing 락이 풀려서 다음 stroke 가 막히지 않는다 (ㄱ → ㅏ hang 회귀 방어). 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 }; - else if (e.type === 'pointerleave') { - // capture 가 활성이면 leave 는 정상 흐름이 아님 — ignore (정상적으로 pointerup - // 이 곧 도착할 것). capture 가 풀렸을 때만 안전망으로 finalize 진행. - if (canvas?.hasPointerCapture?.(e.pointerId)) return; - dbg = { ...dbg, leave: dbg.leave + 1 }; + if (DBG) { + if (e.type === 'pointerup') dbg = { ...dbg, up: dbg.up + 1 }; + else if (e.type === 'pointercancel') dbg = { ...dbg, cancel: dbg.cancel + 1 }; + else if (e.type === 'pointerleave') dbg = { ...dbg, leave: dbg.leave + 1 }; } + // pointerleave 의 capture 활성 가드는 DBG 와 무관하게 적용. + if (e.type === 'pointerleave' && canvas?.hasPointerCapture?.(e.pointerId)) return; + if (!isDrawing) return; if (e.pointerId !== activePointerId) { - dbg = { ...dbg, rejectedByPointerId: dbg.rejectedByPointerId + 1 }; + if (DBG) dbg = { ...dbg, rejectedByPointerId: dbg.rejectedByPointerId + 1 }; return; } @@ -499,6 +503,16 @@ let resizeObserver: ResizeObserver | null = null; function onWindowResize() { resizeCanvas(); } + // window 레벨 안전망 — iOS Safari 가 setPointerCapture 를 silently 풀어 + // pointerup 이 캔버스 element 에 routing 안 되는 케이스 (특히 펜이 캔버스 영역 + // *안*에서 hover 해제될 때 pointerleave 도 미발화) 를 cover. window 에는 + // 거의 항상 도달하므로 isDrawing 락이 영구 풀린다 → ㄱ→ㅏ hang 회귀 차단. + function onWindowPointerEnd(e: PointerEvent) { + if (!isDrawing) return; + if (e.pointerId !== activePointerId) return; + endStroke(e); + } + onMount(() => { try { const txt = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); @@ -515,6 +529,8 @@ window.addEventListener('resize', onWindowResize); window.addEventListener('orientationchange', onWindowResize); window.addEventListener('beforeunload', onBeforeUnload); + window.addEventListener('pointerup', onWindowPointerEnd); + window.addEventListener('pointercancel', onWindowPointerEnd); }); onDestroy(() => { flushSave(); @@ -523,6 +539,8 @@ window.removeEventListener('resize', onWindowResize); window.removeEventListener('orientationchange', onWindowResize); window.removeEventListener('beforeunload', onBeforeUnload); + window.removeEventListener('pointerup', onWindowPointerEnd); + window.removeEventListener('pointercancel', onWindowPointerEnd); } }); @@ -641,7 +659,7 @@ type:{dbg.lastType} p:{dbg.lastPressure.toFixed(2)}
down:{dbg.down} move:{dbg.move} up:{dbg.up} cancel:{dbg.cancel}
rejType:{dbg.rejectedByType} rejId:{dbg.rejectedByPointerId} coal:{dbg.coalesced}
- drawing:{isDrawing ? 'Y' : 'N'} actId:{activePointerId ?? '-'} infPts:{dbgInflightPts}
+ drawing:{isDrawing ? 'Y' : 'N'} actId:{activePointerId ?? '-'} infPts:{inflight?.points.length ?? 0}
strokes:{strokes.length} {/if}