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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user