Files
hyungi_document_server/frontend/src/lib/components/HandwriteCanvas.svelte
T
Hyungi Ahn 85659ce928 fix(study): perfect-freehand 미사용으로 단순 ctx.stroke() 전환 + 좌표 scale 보정
증상: 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 가림 등) 추적.
2026-04-27 09:00:39 +09:00

482 lines
16 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>