fix(study): ㄱ→ㅏ hang + 1사분면 확대 회귀 — pointerleave 안전망 + viewport meta

증상:
- ㄱ stroke 후 ㅏ stroke 가 안 그려짐. iOS Safari 가 setPointerCapture 를 silently
  풀어 pointerup 이 캔버스로 routing 안 되는 케이스에서 isDrawing 락 잔존 → 다음
  pointerdown 이 onPointerDown:298 가드 에서 거부.
- 캔버스가 1사분면으로 확대되는 OS 핀치줌. element-level gesturestart 차단이 일부
  iOS 빌드에서 흡수만 되고 줌이 진행.

A. pointerleave 안전망 (HandwriteCanvas.svelte)
  - onpointerleave={endStroke} 복구.
  - endStroke 내 pointerleave 분기: canvas.hasPointerCapture true 면 ignore (정상
    흐름, pointerup 곧 도착). false 면 안전망 finalize → isDrawing 락 해제.
  - capture 가 정상 잡힌 케이스엔 영향 없음 (leave 자체가 안 옴).

B. viewport meta 강화 ([id]/+page.svelte)
  - maximum-scale=1, user-scalable=no 추가. iOS 13+ 에서 OS 핀치줌 원천 차단.
  - 페이지별 meta 라 다른 페이지 접근성 영향 0. zoom UI 는 Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-27 12:16:45 +09:00
parent 49d8f68986
commit 7f3955c020
2 changed files with 17 additions and 5 deletions
@@ -373,11 +373,19 @@
// pointerup → 정상 finalize. pointercancel → inflight 폐기 (사용자 의도가 아닌
// OS 강제 취소 — multi-touch / 시스템 gesture 인식 시 발생. cancel 된 stroke 가
// strokes 에 들어가면 의도치 않은 짧은 노이즈 stroke 누적, 사용자 글자 망가짐).
// pointerleave 는 핸들러 미바인딩 — setPointerCapture 가 잡히면 leave 자체가 안
// 오고, 캡처 실패 케이스는 OS 가 pointercancel 로 흘려보냄.
// pointerleave 는 안전망 — capture 가 정상 잡혀 있으면 leave 자체가 사양상
// 오므로 무해. 만약 leave 가 도착했다면 iOS Safari 가 capture 를 silently 풀어
// 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 (!isDrawing) return;
if (e.pointerId !== activePointerId) {
dbg = { ...dbg, rejectedByPointerId: dbg.rejectedByPointerId + 1 };
@@ -620,6 +628,7 @@
onpointermove={onPointerMove}
onpointerup={endStroke}
onpointercancel={endStroke}
onpointerleave={endStroke}
oncontextmenu={(e) => e.preventDefault()}
onselectstart={(e) => e.preventDefault()}
class="block"
@@ -152,9 +152,12 @@
</script>
<svelte:head>
<!-- 접근성을 위해 user-scalable / maximum-scale 강제는 적용하지 않는다.
핀치줌 차단은 페이지 root 의 ongesturestart 등으로 영역 제한. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<!-- 학습 캔버스: 핀치줌이 의도 사용 패턴 아님. element-level gesturestart 차단이
일부 iOS Safari 빌드에서 흡수만 되고 줌이 진행되는 케이스 (페이지가 1사분면으로
확대되는 회귀) 를 viewport 레벨에서 원천 차단. iOS 13+ 에서 maximum-scale=1 +
user-scalable=no 조합이 OS 핀치줌 차단으로 동작. 접근성 zoom 은 별도 zoom
버튼으로 Phase 2 에서 제공. 페이지별 meta 라 다른 페이지 영향 없음. -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
</svelte:head>
<div