fix(study): stroke 렌더링 + 부분 지우개 모드
stroke 가 안 그려지는 이슈 수정 + 사용자 요청한 부분 지우개 추가. 렌더링 fix: - last:true 항상 (진행 중 stroke 도 양쪽 outline + cap 완성, polygon 닫힘 보장). 이전엔 inflight 일 때 last:false 라서 outline 한쪽만 그려져 fill 영역 거의 0. - thinning 0.5 → 0.3 (시작/끝 부분이 너무 얇아지지 않게) - baseSize default 4 → 6 - pointermove: main 점을 항상 push (coalesced 는 보간 보조) 부분 지우개: - tool: 'pen' | 'eraser' state. 툴바에 펜/지우개 토글 - eraser 모드: pointer 가 지나가는 stroke 를 점-원 hit-test 로 즉시 삭제 - eraserRadius = baseSize * 4 (최소 16 px) - 삭제된 stroke 는 undoStack 으로 — undo 로 복구 가능 - cursor: eraser 면 'cell', 펜이면 'crosshair' - 전체 지우기는 별도 Trash2 버튼으로 분리
This commit is contained in:
@@ -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<void>;
|
||||
}
|
||||
|
||||
@@ -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<Stroke[]>(initialStrokes?.strokes ?? []);
|
||||
let undoStack = $state<Stroke[]>([]); // 삭제된 stroke 들 (redo 용)
|
||||
let inflight: Stroke | null = $state(null); // 진행 중 stroke (포인터 떼기 전)
|
||||
let undoStack = $state<Stroke[]>([]); // 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<Tool>('pen');
|
||||
|
||||
let isDirty = $state(false);
|
||||
let saveTimer: number | null = null;
|
||||
let snapshotting = $state(false);
|
||||
let snapshotErr = $state<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() {
|
||||
@@ -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 @@
|
||||
<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={Eraser} size="sm" aria-label="모두 지우기" onclick={clearAll} disabled={strokes.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}
|
||||
@@ -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'};"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user