fix(study): ㄱ→ㅏ hang 다중 안전망 — window pointerup + inflight plain + dbg DEV gate
이전 commit (7f3955c) 의 element-level pointerleave 안전망이 부족 — 펜이 캔버스
영역 *안*에서 hover 해제되면 pointerleave 미발화 (pointerout 만), 캔버스 element
의 setPointerCapture 가 silently 풀린 케이스도 캔버스 element 핸들러로 못 잡음.
isDrawing 락이 영구 → 다음 stroke 진입 거부 → ㄱ→ㅏ 회귀 잔존.
A. window 레벨 pointerup/pointercancel 안전망 (핵심)
- window.addEventListener('pointerup'|'pointercancel', onWindowPointerEnd).
- onWindowPointerEnd 가 isDrawing && pointerId == activePointerId 시 endStroke 호출.
- 캔버스 element 의 capture 가 풀려도 window 에는 거의 항상 도달 → 락 영구 해제.
B. inflight 를 $state 에서 plain 변수로
- Svelte 5 deep proxy 가 매 pointermove 의 coalesced push 마다 reactive notify.
60Hz × 8~12 coalesced = 480회/초 의 reactive trigger 가 onPointerMove 핸들러
실행 시간을 누적시켜 native event queue 적체 → capture race 가능성 증가.
- UI 는 redraw 함수가 호출 시점에 inflight 직접 read 하므로 reactive 불필요.
- dbgInflightPts $derived 제거, 패널은 inline `inflight?.points.length` 사용.
C. dbg state mutation DEV 게이트
- DBG = import.meta.env.DEV 상수. 모든 dbg = ... 호출을 if (DBG) 로 감쌈.
- prod 빌드에서 Vite 가 if (false) ... 를 DCE → mutation 비용 0.
- pointerleave 의 capture 활성 가드는 DBG 와 무관하게 항상 적용 (실제 안전망 로직).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,14 +52,20 @@
|
||||
|
||||
let strokes = $state<Stroke[]>(initialStrokes?.strokes ?? []);
|
||||
let undoStack = $state<Stroke[]>([]);
|
||||
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<number | null>(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<Tool>('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)}<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/>
|
||||
drawing:{isDrawing ? 'Y' : 'N'} actId:{activePointerId ?? '-'} infPts:{inflight?.points.length ?? 0}<br/>
|
||||
strokes:{strokes.length}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user