fix(study): 캔버스 풀스크린 + 좌측 floating panel + 좌표 디버그

증상: 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 방어)
This commit is contained in:
Hyungi Ahn
2026-04-27 08:50:39 +09:00
parent df9da33acb
commit 77790d6dc1
2 changed files with 103 additions and 60 deletions
@@ -73,6 +73,9 @@
let snapshotting = $state(false);
let snapshotErr = $state<string | null>(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 @@
</button>
</div>
<!-- 캔버스 컨테이너 -->
<div bind:this={containerEl} class="flex-1 min-h-0 bg-bg relative overflow-hidden">
<!-- 캔버스 컨테이너 — 명확한 border 로 영역 구분 -->
<div bind:this={containerEl} class="flex-1 min-h-0 bg-bg relative overflow-hidden border-2 border-default/30">
<canvas
bind:this={canvas}
onpointerdown={onPointerDown}
@@ -444,5 +458,13 @@
class="block"
style="touch-action: none; cursor: {tool === 'eraser' ? 'cell' : 'crosshair'};"
></canvas>
<!-- 디버그 오버레이 — 좌표 어긋남 디버깅 후 제거 예정 -->
<div class="absolute top-1 right-1 px-2 py-1 rounded bg-bg/80 text-[10px] text-dim font-mono pointer-events-none">
{cssWidth}×{cssHeight}
{#if lastDebug}
· {lastDebug.t} ({lastDebug.x},{lastDebug.y}) p={lastDebug.p.toFixed(2)}
{/if}
</div>
</div>
</div>
@@ -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 @@
}
</script>
<svelte:head>
<title>학습 세션 #{sessionId}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
</svelte:head>
<div class="h-full flex flex-col">
<!-- 상단 액션 -->
<div class="flex items-center justify-between gap-2 px-3 py-2 border-b border-default bg-surface shrink-0">
<div class="h-full flex flex-col relative">
<!-- 상단 헤더 -->
<div class="flex items-center justify-between gap-2 px-3 py-2 border-b border-default bg-surface shrink-0 z-10">
<Button href="/study/write" size="sm" variant="ghost" icon={ArrowLeft}>목록</Button>
<button
type="button"
onclick={() => (panelOpen = !panelOpen)}
class="flex items-center gap-1 px-2 py-1 rounded text-xs text-text hover:bg-bg"
aria-label="메타 패널 토글"
>
{#if panelOpen}
<PanelLeftClose size={14} />
{:else}
<PanelLeftOpen size={14} />
{/if}
패널
</button>
<div class="flex-1 min-w-0 text-center text-xs text-dim truncate">
{#if sess}
{sess.study_type === 'language' ? `${sess.language_code || '?'} · ${sess.learning_level || ''}` : (sess.certification || '자격증')}
· {sess.subject || ''} · {sess.topic || ''}
{/if}
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={bumpRep}
class="flex items-center gap-1 px-2 py-1 rounded text-xs bg-accent/10 text-accent hover:bg-accent/15"
>
<RotateCw size={12} /> 다음 시도 ({repCount})
</button>
</div>
<button
type="button"
onclick={bumpRep}
class="flex items-center gap-1 px-2 py-1 rounded text-xs bg-accent/10 text-accent hover:bg-accent/15"
>
<RotateCw size={12} /> 다음 시도 ({repCount})
</button>
</div>
{#if loading}
<div class="p-4">
<div class="p-4 flex-1">
<Skeleton class="h-96" />
</div>
{:else if !sess}
<div class="p-4 text-sm text-error">세션을 찾을 수 없습니다.</div>
{:else}
<!-- 본문: md 이상 좌우 분할, 모바일은 위/아래 -->
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
<!-- 좌측: 메타/원문/asset -->
<aside class="md:w-80 md:border-r border-default md:overflow-y-auto md:shrink-0 md:max-h-full">
<SourceTextPanel
sourceText={sess.source_text}
sourcePage={sess.source_page}
metadata={sess.metadata}
mode={sess.mode}
studyType={sess.study_type}
/>
<details class="border-b border-default">
<summary class="px-3 py-2 text-xs text-dim cursor-pointer hover:bg-surface">메타 편집</summary>
<StudyMetaEditor meta={sess} onPatch={patchSession} />
</details>
<AssetList assets={sess.assets || []} onLink={linkAsset} onUnlink={unlinkAsset} />
</aside>
<!-- 본문: 캔버스 풀 영역 -->
<div class="flex-1 min-h-0 relative">
<HandwriteCanvas
sessionId={sess.id}
initialStrokes={sess.strokes_json}
traceText={sess.mode === 'trace' ? sess.source_text : null}
baseSize={sess.metadata?.unit_type === 'kanji' ? 8 : 6}
onChange={onStrokesChange}
onSnapshot={onSnapshot}
/>
<!-- 우측: 캔버스 -->
<section class="flex-1 min-h-0 md:min-w-0">
<HandwriteCanvas
sessionId={sess.id}
initialStrokes={sess.strokes_json}
traceText={sess.mode === 'trace' ? sess.source_text : null}
baseSize={sess.metadata?.unit_type === 'kanji' ? 6 : 4}
onChange={onStrokesChange}
onSnapshot={onSnapshot}
/>
</section>
<!-- 좌측 floating panel (toggle) -->
{#if panelOpen}
<button
type="button"
aria-label="패널 닫기"
onclick={() => (panelOpen = false)}
class="absolute inset-0 bg-black/30 z-20"
></button>
<aside
class="absolute top-0 left-0 bottom-0 w-80 max-w-[85vw] bg-surface border-r border-default z-30
overflow-y-auto shadow-2xl"
>
<div class="flex items-center justify-between px-3 py-2 border-b border-default sticky top-0 bg-surface z-10">
<span class="text-sm font-semibold text-text">세션 정보</span>
<button
type="button"
onclick={() => (panelOpen = false)}
class="p-1 rounded hover:bg-bg text-dim"
aria-label="닫기"
><X size={16} /></button>
</div>
<SourceTextPanel
sourceText={sess.source_text}
sourcePage={sess.source_page}
metadata={sess.metadata}
mode={sess.mode}
studyType={sess.study_type}
/>
<details class="border-b border-default">
<summary class="px-3 py-2 text-xs text-dim cursor-pointer hover:bg-bg">메타 편집</summary>
<StudyMetaEditor meta={sess} onPatch={patchSession} />
</details>
<AssetList assets={sess.assets || []} onLink={linkAsset} onUnlink={unlinkAsset} />
</aside>
{/if}
</div>
{/if}
</div>