85659ce928
증상: stroke count 는 올라가는데 화면에 그려지지 않음 + 위치 어긋남. 원인 격리 시도: - perfect-freehand 의 polygon fill 이 일부 환경에서 제대로 그려지지 않는 것으로 보여 단순 ctx.beginPath/moveTo/lineTo/stroke() 로 갈아치움. lineCap/lineJoin 'round' + lineWidth=baseSize 로 자연스러운 라인. 압력 효과는 일시 제거. - getLocalXY 에 scale 보정 추가: canvas.style.width(cssWidth) 와 rect.width 가 다른 ResizeObserver 지연 케이스에서 좌표가 어긋나지 않도록 비율 보정. 이번 변경으로도 stroke 가 안 보이면 디버그 오버레이의 좌표/크기를 보고 다른 경로 (캔버스 자체 비활성, layer 가림 등) 추적.
482 lines
16 KiB
Svelte
482 lines
16 KiB
Svelte
<script lang="ts">
|
||
/**
|
||
* HandwriteCanvas — Apple Pencil + 일반 터치/마우스 손글씨 캔버스.
|
||
*
|
||
* 핵심 (사용자 강조):
|
||
* - PointerEvent 만 사용. pointerType === 'pen' 검사 (palm rejection)
|
||
* - e.pressure (0~1, Pencil) — simulatePressure: true 로 안전망 (속도 기반)
|
||
* - touch-action: none (캔버스 한정)
|
||
* - devicePixelRatio 반영 (retina 선명)
|
||
* - stroke 단위 undo/redo
|
||
* - 5초 idle 또는 stroke 5개마다 onChange — 부모가 PATCH 트리거
|
||
* - 마운트 시 strokes_json 으로 전체 redraw (세션 복원)
|
||
* - localStorage 백업 (네트워크 단절 대비)
|
||
*
|
||
* Tool:
|
||
* - 'pen' (default): 새 stroke 그리기
|
||
* - 'eraser': pointer 가 지나가는 stroke 를 hit-test 로 즉시 삭제
|
||
* (전체 지우기 버튼은 별도)
|
||
*/
|
||
import { onMount, onDestroy } from 'svelte';
|
||
import { Eraser, Pencil, Undo2, Redo2, Trash2 } from 'lucide-svelte';
|
||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||
|
||
type Point = [number, number, number]; // [x, y, pressure]
|
||
type Stroke = {
|
||
id: string;
|
||
points: Point[];
|
||
};
|
||
export type StrokesJson = {
|
||
version: 1;
|
||
strokes: Stroke[];
|
||
};
|
||
type Tool = 'pen' | 'eraser';
|
||
|
||
interface Props {
|
||
sessionId: number;
|
||
initialStrokes?: StrokesJson | null;
|
||
/** 트레이싱 모드 — 캔버스 배경에 회색으로 깔리는 텍스트. */
|
||
traceText?: string | null;
|
||
/** 펜 기본 굵기 (default 6). kanji 면 약간 굵게 권장. */
|
||
baseSize?: number;
|
||
/** PATCH /api/study-sessions/{id} strokes_json 트리거. */
|
||
onChange?: (strokes: StrokesJson) => void;
|
||
/** POST /snapshot — Blob 전달. */
|
||
onSnapshot?: (blob: Blob) => Promise<void>;
|
||
}
|
||
|
||
let {
|
||
sessionId,
|
||
initialStrokes = null,
|
||
traceText = null,
|
||
baseSize = 6,
|
||
onChange,
|
||
onSnapshot,
|
||
}: Props = $props();
|
||
|
||
// ── 상태 ──
|
||
let canvas = $state<HTMLCanvasElement | null>(null);
|
||
let containerEl = $state<HTMLDivElement | null>(null);
|
||
let cssWidth = $state(800);
|
||
let cssHeight = $state(600);
|
||
|
||
let strokes = $state<Stroke[]>(initialStrokes?.strokes ?? []);
|
||
let undoStack = $state<Stroke[]>([]); // redo 큐 (clear 또는 undo 로 빠진 stroke)
|
||
let inflight: Stroke | null = $state(null);
|
||
let activePointerId: number | null = null;
|
||
let strokeColor = $state('#e4e4e7'); // 마운트 시 --text 토큰 실측으로 갱신
|
||
let tool = $state<Tool>('pen');
|
||
|
||
let isDirty = $state(false);
|
||
let saveTimer: number | null = null;
|
||
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));
|
||
|
||
// ── localStorage backup ──
|
||
const lsKey = $derived(`study_session_${sessionId}_strokes`);
|
||
function backupToLocalStorage() {
|
||
try {
|
||
localStorage.setItem(lsKey, JSON.stringify({ version: 1, strokes }));
|
||
} catch {
|
||
// quota / 비활성 — 무시
|
||
}
|
||
}
|
||
function restoreFromLocalStorageIfNewer() {
|
||
try {
|
||
const raw = localStorage.getItem(lsKey);
|
||
if (!raw) return;
|
||
const parsed = JSON.parse(raw) as StrokesJson;
|
||
if ((initialStrokes?.strokes?.length ?? 0) === 0 && parsed.strokes.length > 0) {
|
||
strokes = parsed.strokes;
|
||
scheduleSave();
|
||
}
|
||
} catch {
|
||
// 깨진 데이터 — 무시
|
||
}
|
||
}
|
||
|
||
// ── DPR + resize ──
|
||
function resizeCanvas() {
|
||
if (!canvas || !containerEl) return;
|
||
const rect = containerEl.getBoundingClientRect();
|
||
cssWidth = Math.max(200, Math.floor(rect.width));
|
||
cssHeight = Math.max(200, Math.floor(rect.height));
|
||
const dpr = window.devicePixelRatio || 1;
|
||
canvas.width = cssWidth * dpr;
|
||
canvas.height = cssHeight * dpr;
|
||
canvas.style.width = `${cssWidth}px`;
|
||
canvas.style.height = `${cssHeight}px`;
|
||
const ctx = canvas.getContext('2d');
|
||
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
redraw();
|
||
}
|
||
|
||
// ── render — 단순 ctx.stroke() 로 갈아치움 (perfect-freehand 미사용).
|
||
// 이유: perfect-freehand 의 polygon fill 이 그려지지 않는 보고. 단순 line 으로
|
||
// 안정성 확보 후 문제 격리. 압력 효과는 lineWidth 변경으로 흉내.
|
||
function drawStroke(ctx: CanvasRenderingContext2D, s: Stroke) {
|
||
if (s.points.length === 0) return;
|
||
ctx.beginPath();
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
ctx.lineWidth = baseSize;
|
||
ctx.strokeStyle = strokeColor;
|
||
if (s.points.length === 1) {
|
||
// 점 하나만 있으면 작은 원
|
||
const [x, y] = s.points[0];
|
||
ctx.beginPath();
|
||
ctx.fillStyle = strokeColor;
|
||
ctx.arc(x, y, baseSize / 2, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
return;
|
||
}
|
||
ctx.moveTo(s.points[0][0], s.points[0][1]);
|
||
for (let i = 1; i < s.points.length; i++) {
|
||
ctx.lineTo(s.points[i][0], s.points[i][1]);
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
function drawTraceBackground(ctx: CanvasRenderingContext2D) {
|
||
if (!traceText) return;
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(120, 120, 120, 0.18)';
|
||
const fontSize = Math.min(cssWidth, cssHeight) * 0.4;
|
||
ctx.font = `${fontSize}px sans-serif`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(traceText, cssWidth / 2, cssHeight / 2);
|
||
ctx.restore();
|
||
}
|
||
|
||
function redraw() {
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return;
|
||
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
||
drawTraceBackground(ctx);
|
||
for (const s of strokes) {
|
||
drawStroke(ctx, s);
|
||
}
|
||
if (inflight) {
|
||
drawStroke(ctx, inflight);
|
||
}
|
||
}
|
||
|
||
// ── pointer 헬퍼 ──
|
||
function getLocalXY(e: PointerEvent): [number, number] {
|
||
if (!canvas) return [0, 0];
|
||
const rect = canvas.getBoundingClientRect();
|
||
// canvas 의 실제 표시 크기와 attribute 크기가 다른 경우 (ResizeObserver 가 아직 못 따라잡은 케이스)
|
||
// 비율로 보정. rect.width 가 canvas 의 실제 표시 width.
|
||
const scaleX = rect.width === 0 ? 1 : cssWidth / rect.width;
|
||
const scaleY = rect.height === 0 ? 1 : cssHeight / rect.height;
|
||
return [
|
||
(e.clientX - rect.left) * scaleX,
|
||
(e.clientY - rect.top) * scaleY,
|
||
];
|
||
}
|
||
|
||
function isPenLike(e: PointerEvent): boolean {
|
||
// 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 ──
|
||
/** 주어진 좌표 (x, y) 반경 안에 있는 stroke 들을 strokes 배열에서 제거. */
|
||
function eraseAt(x: number, y: number): boolean {
|
||
if (strokes.length === 0) return false;
|
||
const r2 = eraserRadius * eraserRadius;
|
||
const removed: Stroke[] = [];
|
||
const keep: Stroke[] = [];
|
||
for (const s of strokes) {
|
||
let hit = false;
|
||
// 점-원 hit. 점 사이 라인 hit-test 까진 안 함 (Phase 1 단순화 — 보통 stroke 간격이 촘촘).
|
||
for (const [px, py] of s.points) {
|
||
const dx = px - x;
|
||
const dy = py - y;
|
||
if (dx * dx + dy * dy <= r2) { hit = true; break; }
|
||
}
|
||
(hit ? removed : keep).push(s);
|
||
}
|
||
if (removed.length === 0) return false;
|
||
strokes = keep;
|
||
undoStack = [...undoStack, ...removed];
|
||
return true;
|
||
}
|
||
|
||
// ── pointer 핸들러 ──
|
||
function onPointerDown(e: PointerEvent) {
|
||
if (!canvas || !isPenLike(e)) return;
|
||
e.preventDefault();
|
||
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);
|
||
if (removed) {
|
||
isDirty = true;
|
||
backupToLocalStorage();
|
||
scheduleSave();
|
||
redraw();
|
||
}
|
||
return;
|
||
}
|
||
|
||
inflight = {
|
||
id: `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||
points: [[x, y, e.pressure || 0.5]],
|
||
};
|
||
redraw();
|
||
}
|
||
|
||
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);
|
||
if (removed) {
|
||
isDirty = true;
|
||
backupToLocalStorage();
|
||
scheduleSave();
|
||
redraw();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!inflight) return;
|
||
// coalesced events 가 있으면 보간으로만 사용. main 점은 항상 push 보장.
|
||
const coalesced = (e.getCoalescedEvents?.() ?? []) as PointerEvent[];
|
||
for (const ev of coalesced) {
|
||
const [cx, cy] = getLocalXY(ev);
|
||
inflight.points.push([cx, cy, ev.pressure || 0.5]);
|
||
}
|
||
inflight.points.push([x, y, e.pressure || 0.5]);
|
||
redraw();
|
||
}
|
||
|
||
function endStroke(e: PointerEvent) {
|
||
if (e.pointerId !== activePointerId) return;
|
||
canvas?.releasePointerCapture?.(e.pointerId);
|
||
activePointerId = null;
|
||
|
||
if (tool === 'eraser') {
|
||
// 지우개는 down/move 단계에서 즉시 삭제 + scheduleSave. 끝낼 일 없음.
|
||
return;
|
||
}
|
||
|
||
if (!inflight) return;
|
||
if (inflight.points.length > 1) {
|
||
strokes = [...strokes, inflight];
|
||
undoStack = [];
|
||
isDirty = true;
|
||
backupToLocalStorage();
|
||
scheduleSave();
|
||
}
|
||
inflight = null;
|
||
redraw();
|
||
}
|
||
|
||
// ── undo/redo/clear ──
|
||
function undo() {
|
||
if (strokes.length === 0) return;
|
||
const last = strokes[strokes.length - 1];
|
||
strokes = strokes.slice(0, -1);
|
||
undoStack = [...undoStack, last];
|
||
isDirty = true;
|
||
backupToLocalStorage();
|
||
scheduleSave();
|
||
redraw();
|
||
}
|
||
function redo() {
|
||
if (undoStack.length === 0) return;
|
||
const last = undoStack[undoStack.length - 1];
|
||
undoStack = undoStack.slice(0, -1);
|
||
strokes = [...strokes, last];
|
||
isDirty = true;
|
||
backupToLocalStorage();
|
||
scheduleSave();
|
||
redraw();
|
||
}
|
||
function clearAll() {
|
||
if (strokes.length === 0) return;
|
||
if (!confirm('이 세션의 모든 stroke 를 지웁니다. 계속할까요?')) return;
|
||
undoStack = [...undoStack, ...strokes];
|
||
strokes = [];
|
||
isDirty = true;
|
||
backupToLocalStorage();
|
||
scheduleSave();
|
||
redraw();
|
||
}
|
||
|
||
// ── 자동 저장 디바운스 ──
|
||
function scheduleSave() {
|
||
if (saveTimer) clearTimeout(saveTimer);
|
||
saveTimer = window.setTimeout(flushSave, 5000) as unknown as number;
|
||
if (strokes.length > 0 && strokes.length % 5 === 0) {
|
||
flushSave();
|
||
}
|
||
}
|
||
function flushSave() {
|
||
if (saveTimer) {
|
||
clearTimeout(saveTimer);
|
||
saveTimer = null;
|
||
}
|
||
if (!isDirty) return;
|
||
isDirty = false;
|
||
onChange?.({ version: 1, strokes });
|
||
}
|
||
|
||
// ── snapshot (PNG) ──
|
||
async function takeSnapshot() {
|
||
if (!canvas || !onSnapshot) return;
|
||
if (snapshotting) return;
|
||
snapshotting = true;
|
||
snapshotErr = null;
|
||
try {
|
||
redraw();
|
||
flushSave();
|
||
const blob: Blob | null = await new Promise((resolve) =>
|
||
canvas!.toBlob((b) => resolve(b), 'image/png')
|
||
);
|
||
if (!blob) throw new Error('PNG 생성 실패');
|
||
await onSnapshot(blob);
|
||
} catch (e) {
|
||
snapshotErr = (e as { detail?: string; message?: string }).detail
|
||
|| (e as Error).message
|
||
|| '스냅샷 저장 실패';
|
||
} finally {
|
||
snapshotting = false;
|
||
}
|
||
}
|
||
|
||
function onBeforeUnload() {
|
||
if (isDirty) flushSave();
|
||
}
|
||
|
||
// ── 마운트 ──
|
||
let resizeObserver: ResizeObserver | null = null;
|
||
function onWindowResize() { resizeCanvas(); }
|
||
onMount(() => {
|
||
try {
|
||
const txt = getComputedStyle(document.documentElement).getPropertyValue('--text').trim();
|
||
if (txt) strokeColor = txt;
|
||
} catch {
|
||
// SSR / 비표준 환경 — fallback 유지
|
||
}
|
||
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(() => {
|
||
flushSave();
|
||
resizeObserver?.disconnect();
|
||
if (typeof window !== 'undefined') {
|
||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||
window.removeEventListener('resize', onWindowResize);
|
||
window.removeEventListener('orientationchange', onWindowResize);
|
||
}
|
||
});
|
||
|
||
$effect(() => {
|
||
if (initialStrokes && initialStrokes.strokes !== strokes) {
|
||
strokes = initialStrokes.strokes;
|
||
undoStack = [];
|
||
redraw();
|
||
}
|
||
});
|
||
$effect(() => {
|
||
void traceText;
|
||
redraw();
|
||
});
|
||
</script>
|
||
|
||
<div class="flex flex-col h-full">
|
||
<!-- 툴바 -->
|
||
<div class="flex items-center gap-1 px-2 py-1 border-b border-default bg-surface shrink-0">
|
||
<!-- Pen / Eraser 모드 토글 -->
|
||
<button
|
||
type="button"
|
||
onclick={() => (tool = 'pen')}
|
||
class="flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
|
||
{tool === 'pen' ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface'}"
|
||
aria-pressed={tool === 'pen'}
|
||
>
|
||
<Pencil size={14} /> 펜
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onclick={() => (tool = 'eraser')}
|
||
class="flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
|
||
{tool === 'eraser' ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface'}"
|
||
aria-pressed={tool === 'eraser'}
|
||
>
|
||
<Eraser size={14} /> 지우개
|
||
</button>
|
||
|
||
<span class="w-px h-5 bg-default mx-1" aria-hidden></span>
|
||
|
||
<IconButton icon={Undo2} size="sm" aria-label="실행 취소" onclick={undo} disabled={strokes.length === 0} />
|
||
<IconButton icon={Redo2} size="sm" aria-label="다시 실행" onclick={redo} disabled={undoStack.length === 0} />
|
||
<IconButton icon={Trash2} size="sm" aria-label="모두 지우기" onclick={clearAll} disabled={strokes.length === 0} />
|
||
|
||
<span class="text-xs text-dim ml-2">stroke {strokes.length}</span>
|
||
<div class="flex-1"></div>
|
||
{#if snapshotErr}
|
||
<span class="text-xs text-error mr-2">{snapshotErr}</span>
|
||
{/if}
|
||
<button
|
||
type="button"
|
||
onclick={takeSnapshot}
|
||
disabled={snapshotting || strokes.length === 0}
|
||
class="px-3 py-1 rounded text-sm bg-accent text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{snapshotting ? '저장 중...' : 'PNG 저장'}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 캔버스 컨테이너 — 명확한 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}
|
||
onpointermove={onPointerMove}
|
||
onpointerup={endStroke}
|
||
onpointercancel={endStroke}
|
||
onpointerleave={(e) => { if (e.pointerId === activePointerId) endStroke(e); }}
|
||
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>
|