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}