From 77790d6dc1ce1d8d25cc1412cf8dbd9e3c31693c Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 27 Apr 2026 08:50:39 +0900 Subject: [PATCH] =?UTF-8?q?fix(study):=20=EC=BA=94=EB=B2=84=EC=8A=A4=20?= =?UTF-8?q?=ED=92=80=EC=8A=A4=ED=81=AC=EB=A6=B0=20+=20=EC=A2=8C=EC=B8=A1?= =?UTF-8?q?=20floating=20panel=20+=20=EC=A2=8C=ED=91=9C=20=EB=94=94?= =?UTF-8?q?=EB=B2=84=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: iPad 에서 펜 입력이 안 들어가거나 다른 위치에 그려지는 보고. 원인은 좌우 분할 layout 에서 우측 캔버스 영역이 좁거나 layout 이 stale. UI: - /study/write/[id] layout 을 캔버스 풀스크린 + 좌측 floating panel 로 변경 - 헤더에 패널 토글 버튼. 패널 default closed → 캔버스가 화면 거의 전체 - 캔버스 컨테이너에 border-default/30 추가 (영역 가시화) 좌표/입력: - isPenLike: 'touch' 도 허용 (iPad 일부 빌드에서 Pencil 이 'pen' 으로 안 들어오는 케이스 방어) - 디버그 오버레이: 캔버스 크기 + 마지막 pointer 좌표/pressure/type 표시 - ResizeObserver 외에 window resize / orientationchange 리스너 추가 - 마운트 직후 RAF×2 후 한 번 더 resizeCanvas (flex 레이아웃 0x0 첫 paint 방어) --- .../src/lib/components/HandwriteCanvas.svelte | 30 +++- .../src/routes/study/write/[id]/+page.svelte | 133 ++++++++++-------- 2 files changed, 103 insertions(+), 60 deletions(-) diff --git a/frontend/src/lib/components/HandwriteCanvas.svelte b/frontend/src/lib/components/HandwriteCanvas.svelte index 48952ab..d4c9c83 100644 --- a/frontend/src/lib/components/HandwriteCanvas.svelte +++ b/frontend/src/lib/components/HandwriteCanvas.svelte @@ -73,6 +73,9 @@ let snapshotting = $state(false); let snapshotErr = $state(null); + // 디버그: 캔버스 크기 + 마지막 pointer 정보. 좌표 어긋남 디버깅용. + let lastDebug = $state<{x:number;y:number;p:number;t:string} | null>(null); + // 지우개 반경 (CSS px) — baseSize 의 약 4배. Apple Pencil 정밀도와 균형. let eraserRadius = $derived(Math.max(16, baseSize * 4)); @@ -170,8 +173,10 @@ } function isPenLike(e: PointerEvent): boolean { - // pen / mouse 는 stroke. touch 거부 — palm rejection. - return e.pointerType === 'pen' || e.pointerType === 'mouse'; + // pen / mouse / touch 모두 stroke 허용. iPad Apple Pencil 이 'pen' 으로 안 들어오는 + // 케이스 보고됨 (브라우저/OS 버전에 따라). 우선 입력 가능성을 넓히고, palm rejection 은 + // Phase 2 에서 동시 다중 pointer 거부 등 다른 방식으로 처리. + return e.pointerType === 'pen' || e.pointerType === 'mouse' || e.pointerType === 'touch'; } // ── 지우개 hit-test ── @@ -204,6 +209,7 @@ canvas.setPointerCapture(e.pointerId); activePointerId = e.pointerId; const [x, y] = getLocalXY(e); + lastDebug = { x: Math.round(x), y: Math.round(y), p: e.pressure, t: e.pointerType }; if (tool === 'eraser') { const removed = eraseAt(x, y); @@ -226,6 +232,7 @@ function onPointerMove(e: PointerEvent) { if (e.pointerId !== activePointerId) return; const [x, y] = getLocalXY(e); + lastDebug = { x: Math.round(x), y: Math.round(y), p: e.pressure, t: e.pointerType }; if (tool === 'eraser') { const removed = eraseAt(x, y); @@ -350,6 +357,7 @@ // ── 마운트 ── let resizeObserver: ResizeObserver | null = null; + function onWindowResize() { resizeCanvas(); } onMount(() => { try { const txt = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); @@ -359,12 +367,16 @@ } resizeCanvas(); restoreFromLocalStorageIfNewer(); + // layout 이 안정된 후 한 번 더 (flex 레이아웃 첫 paint 직후 0x0 케이스 방어) + requestAnimationFrame(() => requestAnimationFrame(resizeCanvas)); redraw(); if (containerEl && 'ResizeObserver' in window) { resizeObserver = new ResizeObserver(() => resizeCanvas()); resizeObserver.observe(containerEl); } + window.addEventListener('resize', onWindowResize); + window.addEventListener('orientationchange', onWindowResize); window.addEventListener('beforeunload', onBeforeUnload); }); onDestroy(() => { @@ -372,6 +384,8 @@ resizeObserver?.disconnect(); if (typeof window !== 'undefined') { window.removeEventListener('beforeunload', onBeforeUnload); + window.removeEventListener('resize', onWindowResize); + window.removeEventListener('orientationchange', onWindowResize); } }); @@ -432,8 +446,8 @@ - -
+ +
+ + +
+ {cssWidth}×{cssHeight} + {#if lastDebug} + · {lastDebug.t} ({lastDebug.x},{lastDebug.y}) p={lastDebug.p.toFixed(2)} + {/if} +
diff --git a/frontend/src/routes/study/write/[id]/+page.svelte b/frontend/src/routes/study/write/[id]/+page.svelte index 3af5ee1..119f4c1 100644 --- a/frontend/src/routes/study/write/[id]/+page.svelte +++ b/frontend/src/routes/study/write/[id]/+page.svelte @@ -2,18 +2,15 @@ /** * /study/write/[id] — 학습 세션 작업 화면. * - * 좌측: SourceTextPanel + StudyMetaEditor + AssetList - * 우측: HandwriteCanvas (Apple Pencil) - * - * 자동 저장: HandwriteCanvas onChange → PATCH strokes_json - * snapshot: HandwriteCanvas onSnapshot → POST /snapshot (multipart) + * 캔버스 풀스크린 + 좌측 floating panel (default closed). iPad portrait / + * 모바일에서도 캔버스가 화면을 거의 전부 차지하도록. + * 메타 편집 / asset 목록은 헤더 "패널" 버튼으로 열고 닫는다. */ import { onMount } from 'svelte'; import { page } from '$app/stores'; - import { goto } from '$app/navigation'; import { api, uploadFile } from '$lib/api'; import { addToast } from '$lib/stores/toast'; - import { ArrowLeft, RotateCw } from 'lucide-svelte'; + import { ArrowLeft, RotateCw, PanelLeftOpen, PanelLeftClose, X } from 'lucide-svelte'; import Button from '$lib/components/ui/Button.svelte'; import Skeleton from '$lib/components/ui/Skeleton.svelte'; import HandwriteCanvas from '$lib/components/HandwriteCanvas.svelte'; @@ -25,6 +22,7 @@ let sess = $state(null); let loading = $state(true); let repCount = $state(0); + let panelOpen = $state(false); async function load() { loading = true; @@ -53,14 +51,12 @@ } async function onStrokesChange(strokesJson) { - // Phase 1: 부모에서 디바운스 안 하고 자식 (HandwriteCanvas) 디바운스를 신뢰. try { await api(`/study-sessions/${sessionId}`, { method: 'PATCH', body: JSON.stringify({ strokes_json: strokesJson }), }); } catch (err) { - // 자동 저장 실패는 toast 한 번만 (스팸 방지). HandwriteCanvas 내부 localStorage backup 이 보호. console.warn('strokes 저장 실패', err); } } @@ -72,7 +68,6 @@ try { await uploadFile(`/study-sessions/${sessionId}/snapshot`, fd); addToast('success', 'PNG 스냅샷 저장됨'); - // assets 갱신 await load(); } catch (err) { addToast('error', err.detail || '스냅샷 저장 실패'); @@ -113,68 +108,94 @@ } - - 학습 세션 #{sessionId} - - - -
- -
+
+ +
+ + +
{#if sess} {sess.study_type === 'language' ? `${sess.language_code || '?'} · ${sess.learning_level || ''}` : (sess.certification || '자격증')} · {sess.subject || ''} · {sess.topic || ''} {/if}
-
- -
+ +
{#if loading} -
+
{:else if !sess}
세션을 찾을 수 없습니다.
{:else} - -
- - + +
+ - -
- -
+ + {#if panelOpen} + + + {/if}
{/if}