diff --git a/frontend/src/lib/components/HandwriteCanvas.svelte b/frontend/src/lib/components/HandwriteCanvas.svelte index 2081119..48952ab 100644 --- a/frontend/src/lib/components/HandwriteCanvas.svelte +++ b/frontend/src/lib/components/HandwriteCanvas.svelte @@ -4,19 +4,22 @@ * * 핵심 (사용자 강조): * - PointerEvent 만 사용. pointerType === 'pen' 검사 (palm rejection) - * - e.pressure (0~1, Pencil) / e.tiltX / e.tiltY → perfect-freehand - * - touch-action: none (캔버스 한정 — 페이지 다른 영역 영향 없음) + * - 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 백업 (네트워크 단절 대비) * - * Phase 1 단순화: 검정 단색, 단일 굵기. 색/굵기 팔레트는 Phase 2+. + * Tool: + * - 'pen' (default): 새 stroke 그리기 + * - 'eraser': pointer 가 지나가는 stroke 를 hit-test 로 즉시 삭제 + * (전체 지우기 버튼은 별도) */ import { onMount, onDestroy } from 'svelte'; import { getStroke } from 'perfect-freehand'; - import { Eraser, Undo2, Redo2 } from 'lucide-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] @@ -28,17 +31,18 @@ version: 1; strokes: Stroke[]; }; + type Tool = 'pen' | 'eraser'; interface Props { sessionId: number; initialStrokes?: StrokesJson | null; - /** 트레이싱 모드 — 캔버스 배경에 회색으로 깔리는 텍스트. Phase 1 에선 단순 텍스트만. */ + /** 트레이싱 모드 — 캔버스 배경에 회색으로 깔리는 텍스트. */ traceText?: string | null; - /** 펜 하단 굵기 (default 4). kanji 면 약간 굵게 권장. */ + /** 펜 기본 굵기 (default 6). kanji 면 약간 굵게 권장. */ baseSize?: number; - /** 부모가 PATCH /api/study-sessions/{id} 로 strokes_json 저장. */ + /** PATCH /api/study-sessions/{id} strokes_json 트리거. */ onChange?: (strokes: StrokesJson) => void; - /** 부모가 POST /snapshot 로 PNG 업로드 — Blob 전달. */ + /** POST /snapshot — Blob 전달. */ onSnapshot?: (blob: Blob) => Promise; } @@ -46,7 +50,7 @@ sessionId, initialStrokes = null, traceText = null, - baseSize = 4, + baseSize = 6, onChange, onSnapshot, }: Props = $props(); @@ -58,16 +62,20 @@ let cssHeight = $state(600); let strokes = $state(initialStrokes?.strokes ?? []); - let undoStack = $state([]); // 삭제된 stroke 들 (redo 용) - let inflight: Stroke | null = $state(null); // 진행 중 stroke (포인터 떼기 전) + let undoStack = $state([]); // redo 큐 (clear 또는 undo 로 빠진 stroke) + let inflight: Stroke | null = $state(null); let activePointerId: number | null = null; - let strokeColor = $state('#e4e4e7'); // 기본은 dark mode 의 --text. 마운트 시 실측으로 갱신 + let strokeColor = $state('#e4e4e7'); // 마운트 시 --text 토큰 실측으로 갱신 + let tool = $state('pen'); let isDirty = $state(false); let saveTimer: number | null = null; let snapshotting = $state(false); let snapshotErr = $state(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() { @@ -82,17 +90,16 @@ const raw = localStorage.getItem(lsKey); if (!raw) return; const parsed = JSON.parse(raw) as StrokesJson; - // initialStrokes 가 비어있고 localStorage 에 데이터 있으면 복원 (네트워크 단절 후 새로고침 대비) if ((initialStrokes?.strokes?.length ?? 0) === 0 && parsed.strokes.length > 0) { strokes = parsed.strokes; - scheduleSave(); // 서버에 푸시 + scheduleSave(); } } catch { // 깨진 데이터 — 무시 } } - // ── DPR 반영 + resize ── + // ── DPR + resize ── function resizeCanvas() { if (!canvas || !containerEl) return; const rect = containerEl.getBoundingClientRect(); @@ -109,16 +116,14 @@ } // ── render ── - function strokeToPath(stroke: Stroke): Path2D { - const outlinePoints = getStroke(stroke.points, { + function strokeToPath(_stroke: Stroke): Path2D { + const outlinePoints = getStroke(_stroke.points, { size: baseSize, - thinning: 0.5, + thinning: 0.3, // 시작 부분이 너무 얇아지지 않게 (0.5 → 0.3) smoothing: 0.5, streamline: 0.4, - // 압력이 0 으로 들어오는 케이스(빠른 펜 입력 / 일부 Safari 빌드) 방어 — 속도 기반 시뮬레이션. - // 실제 Pencil 압력은 무시되지만 사용 일관성이 더 중요. - simulatePressure: true, - last: stroke !== inflight, // 진행 중 stroke 면 false → 끝점 둥글게 마감 안 함 + simulatePressure: true, // pressure 0 으로 들어오는 케이스 방어 + last: true, // 진행 중에도 양쪽 outline + cap 완성 (polygon 닫힘 보장) }); const path = new Path2D(); if (outlinePoints.length === 0) return path; @@ -133,8 +138,7 @@ function drawTraceBackground(ctx: CanvasRenderingContext2D) { if (!traceText) return; ctx.save(); - ctx.fillStyle = 'rgba(120, 120, 120, 0.18)'; // 옅은 회색, 토큰 미사용 — 캔버스 직접 페인트라 lint:tokens 적용 불가 - // 단순 가이드: 텍스트를 캔버스 중앙에 큰 폰트로 깐다. 줄바꿈 없음 (Phase 1). + 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'; @@ -150,15 +154,15 @@ ctx.clearRect(0, 0, cssWidth, cssHeight); drawTraceBackground(ctx); ctx.fillStyle = strokeColor; - for (const stroke of strokes) { - ctx.fill(strokeToPath(stroke)); + for (const s of strokes) { + ctx.fill(strokeToPath(s)); } if (inflight) { ctx.fill(strokeToPath(inflight)); } } - // ── pointer 핸들러 ── + // ── pointer 헬퍼 ── function getLocalXY(e: PointerEvent): [number, number] { if (!canvas) return [0, 0]; const rect = canvas.getBoundingClientRect(); @@ -166,17 +170,52 @@ } function isPenLike(e: PointerEvent): boolean { - // pen / mouse 는 stroke. touch (손가락) 는 거부 — palm rejection. - // 데스크톱 마우스 테스트도 가능하게 mouse 허용. iPad 에선 pen 만 들어옴. + // pen / mouse 는 stroke. touch 거부 — palm rejection. return e.pointerType === 'pen' || e.pointerType === 'mouse'; } + // ── 지우개 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); + + 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]], @@ -185,25 +224,45 @@ } function onPointerMove(e: PointerEvent) { - if (!inflight || e.pointerId !== activePointerId) return; + if (e.pointerId !== activePointerId) return; const [x, y] = getLocalXY(e); - // 일부 브라우저는 coalesced events 제공 — 곡선 부드러움 향상. - const events = (e.getCoalescedEvents?.() ?? [e]) as PointerEvent[]; - for (const ev of events) { + + 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]); } - if (events.length === 0) inflight.points.push([x, y, e.pressure || 0.5]); + inflight.points.push([x, y, e.pressure || 0.5]); redraw(); } function endStroke(e: PointerEvent) { - if (!inflight || e.pointerId !== activePointerId) return; + 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 = []; // 새 stroke 가 들어오면 redo 큐 비움 (Anki 식) + undoStack = []; isDirty = true; backupToLocalStorage(); scheduleSave(); @@ -248,7 +307,6 @@ function scheduleSave() { if (saveTimer) clearTimeout(saveTimer); saveTimer = window.setTimeout(flushSave, 5000) as unknown as number; - // stroke 5개 마다 즉시 flush if (strokes.length > 0 && strokes.length % 5 === 0) { flushSave(); } @@ -270,9 +328,7 @@ snapshotting = true; snapshotErr = null; try { - // 진행 중 stroke 가 있다면 마지막 redraw 보장 redraw(); - // 미저장 stroke 가 있으면 먼저 flush flushSave(); const blob: Blob | null = await new Promise((resolve) => canvas!.toBlob((b) => resolve(b), 'image/png') @@ -288,7 +344,6 @@ } } - // ── beforeunload flush ── function onBeforeUnload() { if (isDirty) flushSave(); } @@ -296,7 +351,6 @@ // ── 마운트 ── let resizeObserver: ResizeObserver | null = null; onMount(() => { - // --text 토큰을 실측해서 stroke 색에 반영. dark/light 모두 자동 대응. try { const txt = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); if (txt) strokeColor = txt; @@ -321,7 +375,6 @@ } }); - // initialStrokes 가 부모에서 바뀌면 (예: navigate) 따라가기 $effect(() => { if (initialStrokes && initialStrokes.strokes !== strokes) { strokes = initialStrokes.strokes; @@ -329,7 +382,6 @@ redraw(); } }); - // traceText 변경 시 redraw $effect(() => { void traceText; redraw(); @@ -339,9 +391,32 @@
+ + + + + + - + + stroke {strokes.length}
{#if snapshotErr} @@ -367,7 +442,7 @@ onpointercancel={endStroke} onpointerleave={(e) => { if (e.pointerId === activePointerId) endStroke(e); }} class="block" - style="touch-action: none; cursor: crosshair;" + style="touch-action: none; cursor: {tool === 'eraser' ? 'cell' : 'crosshair'};" >