b45091c8cb
사용자 보고: "펜이나 지우개를 누르면 자동으로 해당 부분 확대". iOS Safari 의 button
focus 가 mousedown/pointerdown 단계에 발동 → 그 영역으로 자동 zoom in. click 시점의
clickThenBlur 는 이미 늦음 (focus 잡힌 후 blur 시켜도 zoom 유지).
Fix: 모든 toolbar / header button 에 onmousedown={preventDefault} +
onpointerdown={preventDefault} 추가. focus 자체가 안 잡혀서 zoom trigger 없음.
click 이벤트는 별도라 onclick 정상 작동. clickThenBlur 는 잔존 케이스 2차 안전망으로 유지.
대상 buttons:
- HandwriteCanvas toolbar: 펜 / 지우개 / 가늘게/보통/굵게 / Undo/Redo/Trash / PNG 저장
- [id]/+page 헤더: 패널 토글 / 다음 시도
IconButton.svelte Props 에 onmousedown/onpointerdown prop 명시 추가 (기존
{...rest} spread 가 button element 로 전달은 됐지만 TypeScript caller 측 type
narrow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
767 lines
29 KiB
Svelte
767 lines
29 KiB
Svelte
<script lang="ts">
|
||
/**
|
||
* HandwriteCanvas — Apple Pencil 필기 엔진 (전면 재작성).
|
||
*
|
||
* 4축 fix 통합:
|
||
* 1) 입력 수집: PointerEvent + getCoalescedEvents() + setPointerCapture
|
||
* - pointerType === 'pen' 만 인식 (mouse 도 데스크톱 테스트용)
|
||
* - isDrawing flag + activePointerId 매칭
|
||
* - pointerup/cancel/leave 통합 finalize
|
||
* 2) 렌더링: perfect-freehand getStroke → SVG path → Path2D → ctx.fill()
|
||
* - getSvgPathFromStroke 표준 builder
|
||
* - thinning/smoothing/streamline 튜닝
|
||
* - pressure 0 fallback (점선 방지)
|
||
* 3) 입력/저장 분리: autosave 는 3초 idle debounce + setTimeout 0
|
||
* 4) Safari hardening: touch-action / user-select / -webkit-touch-callout
|
||
* / oncontextmenu / onselectstart 차단. button 은 clickThenBlur + tabindex=-1.
|
||
*/
|
||
import { onMount, onDestroy } from 'svelte';
|
||
import { getStroke } from 'perfect-freehand';
|
||
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]
|
||
// _path2d / _size 는 런타임 캐시 — 직렬화 시 제외 (밑줄 prefix 가 marker).
|
||
// 매 RAF frame 마다 모든 stroke 의 perfect-freehand outline 을 재계산하던
|
||
// R3 hot path 를 stroke 당 1회로 줄여 frame budget 초과 (= 입력 적체) 방지.
|
||
type Stroke = {
|
||
id: string;
|
||
points: Point[];
|
||
_path2d?: Path2D;
|
||
_size?: number;
|
||
};
|
||
export type StrokesJson = { version: 1; strokes: Stroke[] };
|
||
type Tool = 'pen' | 'eraser';
|
||
type WidthMode = 'thin' | 'normal' | 'thick';
|
||
|
||
interface Props {
|
||
sessionId: number;
|
||
initialStrokes?: StrokesJson | null;
|
||
traceText?: string | null;
|
||
baseSize?: number;
|
||
onChange?: (strokes: StrokesJson) => void;
|
||
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[]>([]);
|
||
// inflight 는 plain 변수 — Svelte 5 deep proxy 가 매 pointermove 의 coalesced
|
||
// push 마다 reactive notify 하면서 onPointerMove 핸들러 실행 시간 누적 → 다음
|
||
// pointermove 가 native queue 에 적체 → 빠른 stroke 시 capture race 가능성.
|
||
// UI 는 redraw 함수가 호출 시점에 inflight 를 직접 읽으므로 reactive 불필요.
|
||
let inflight: Stroke | null = null;
|
||
|
||
// 입력 상태머신 — 다음 stroke 가 막히지 않게 명확히 관리. $state 유지 (디버그
|
||
// 패널 표시 + 새 stroke 진입 가드 readability).
|
||
let isDrawing = $state(false);
|
||
let activePointerId = $state<number | null>(null);
|
||
|
||
// ── 디버그 카운터 — DEV 빌드 또는 prod 에서 ?debug=1 query 시 활성화.
|
||
// prod 에서 mutation 자체를 DCE 하려면 import.meta.env.DEV 단독이지만,
|
||
// 사용자 iPad 진단을 위해 prod 에서도 query 로 토글 가능하게 함. const 라
|
||
// 페이지 로드 시 한 번만 평가 (성능 영향 미미).
|
||
const DBG = import.meta.env.DEV ||
|
||
(typeof window !== 'undefined' && /[?&]debug=1\b/.test(window.location.search));
|
||
let dbg = $state({
|
||
down: 0, move: 0, up: 0, cancel: 0, leave: 0,
|
||
rejectedByType: 0, rejectedByPointerId: 0,
|
||
coalesced: 0,
|
||
lastType: '-',
|
||
lastPressure: 0,
|
||
});
|
||
|
||
let strokeColor = $state('#e4e4e7');
|
||
let tool = $state<Tool>('pen');
|
||
let widthMode = $state<WidthMode>('normal');
|
||
const WIDTH_FACTOR: Record<WidthMode, number> = { thin: 0.6, normal: 1, thick: 1.6 };
|
||
let effectiveSize = $derived(baseSize * WIDTH_FACTOR[widthMode]);
|
||
let eraserRadius = $derived(Math.max(16, effectiveSize * 4));
|
||
|
||
// 지우개 이동 경로의 직전 점. pointerdown 에서 set, pointermove 에서 segment
|
||
// 시작점, end 에서 null. $state 아님 — 입력 루프 내부 값 (UI 미참조).
|
||
let eraserLast: [number, number] | null = null;
|
||
|
||
let isDirty = false;
|
||
let saveTimer: number | null = null;
|
||
let snapshotting = $state(false);
|
||
let snapshotErr = $state<string | null>(null);
|
||
|
||
// ── localStorage ──
|
||
const lsKey = $derived(`study_session_${sessionId}_strokes`);
|
||
// Path2D 등 런타임 캐시 (`_` prefix) 제외하고 직렬화. 서버/localStorage 에는 순수
|
||
// {id, points} 만 저장.
|
||
function serializableStrokes(): Pick<Stroke, 'id' | 'points'>[] {
|
||
return strokes.map((s) => ({ id: s.id, points: s.points }));
|
||
}
|
||
// backup 은 stroke 완료마다 호출되지만 실제 sync I/O (JSON.stringify + localStorage
|
||
// .setItem) 는 500ms idle 시 1회만. stroke 73 × 30점 = 65KB+ JSON 의 sync write 가
|
||
// iPad CPU 에서 50~200ms main thread block → 다음 펜 입력 손실 (ㄱ 직후 ㅏ 안
|
||
// 들어가는 회귀의 진짜 root cause). 빠른 연속 stroke 시 backup 0회 → block 0.
|
||
let backupTimer: number | null = null;
|
||
function backup() {
|
||
if (backupTimer) clearTimeout(backupTimer);
|
||
backupTimer = window.setTimeout(flushBackup, 500) as unknown as number;
|
||
}
|
||
function flushBackup() {
|
||
if (backupTimer) { clearTimeout(backupTimer); backupTimer = null; }
|
||
try {
|
||
localStorage.setItem(
|
||
lsKey,
|
||
JSON.stringify({ version: 1, strokes: serializableStrokes() }),
|
||
);
|
||
} catch {}
|
||
}
|
||
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 (layout/orientation 변화 시에만) ──
|
||
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);
|
||
scheduleRedraw();
|
||
}
|
||
|
||
// ── render (perfect-freehand polygon fill) ──
|
||
// perfect-freehand README 의 표준 SVG path builder.
|
||
function getSvgPathFromStroke(stroke: number[][]): string {
|
||
if (!stroke.length) return '';
|
||
const d: (string | number)[] = ['M', ...stroke[0], 'Q'];
|
||
for (let i = 0; i < stroke.length; i++) {
|
||
const [x0, y0] = stroke[i];
|
||
const [x1, y1] = stroke[(i + 1) % stroke.length];
|
||
d.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2);
|
||
}
|
||
d.push('Z');
|
||
return d.join(' ');
|
||
}
|
||
|
||
function drawStroke(ctx: CanvasRenderingContext2D, s: Stroke, isInflight = false) {
|
||
const pts = s.points;
|
||
if (pts.length === 0) return;
|
||
|
||
if (pts.length === 1) {
|
||
const [x, y, p] = pts[0];
|
||
const r = effectiveSize * (0.4 + p * 0.6) / 2;
|
||
ctx.fillStyle = strokeColor;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, Math.max(1, r), 0, Math.PI * 2);
|
||
ctx.fill();
|
||
return;
|
||
}
|
||
|
||
// 완료 stroke 는 캐시된 Path2D 재사용 (size 변경 시만 재계산). inflight 는
|
||
// 점이 매 frame 추가되므로 매번 재계산.
|
||
let path = !isInflight && s._size === effectiveSize ? s._path2d : undefined;
|
||
if (!path) {
|
||
const outline = getStroke(pts, {
|
||
size: effectiveSize,
|
||
thinning: 0.25,
|
||
smoothing: 0.85,
|
||
streamline: 0.65,
|
||
simulatePressure: false,
|
||
last: !isInflight,
|
||
});
|
||
if (outline.length < 2) return;
|
||
path = new Path2D(getSvgPathFromStroke(outline));
|
||
if (!isInflight) {
|
||
s._path2d = path;
|
||
s._size = effectiveSize;
|
||
}
|
||
}
|
||
ctx.fillStyle = strokeColor;
|
||
ctx.fill(path);
|
||
}
|
||
|
||
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, false);
|
||
if (inflight) drawStroke(ctx, inflight, true);
|
||
}
|
||
|
||
// RAF throttle — pointermove 가 60Hz 보다 빠르게 들어와도 redraw 는 frame 당 1회.
|
||
let rafScheduled = false;
|
||
function scheduleRedraw() {
|
||
if (rafScheduled) return;
|
||
rafScheduled = true;
|
||
requestAnimationFrame(() => {
|
||
rafScheduled = false;
|
||
redraw();
|
||
});
|
||
}
|
||
|
||
// ── pointer ──
|
||
function getLocalXY(e: PointerEvent): [number, number] {
|
||
if (!canvas) return [0, 0];
|
||
const rect = canvas.getBoundingClientRect();
|
||
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 {
|
||
// Apple Pencil ('pen') 만 stroke 입력. 손가락 ('touch') 거부 — palm rejection.
|
||
// mouse 는 데스크톱 테스트 허용.
|
||
return e.pointerType === 'pen' || e.pointerType === 'mouse';
|
||
}
|
||
|
||
function normalizePressure(p: number | undefined): number {
|
||
if (typeof p !== 'number' || !Number.isFinite(p) || p <= 0.05) return 0.5;
|
||
return Math.min(1, p);
|
||
}
|
||
|
||
// 점 사이 거리가 너무 멀면 중간 점 보간 — 빠른 stroke 의 sparse point 점선 방지.
|
||
// iPad 60Hz pointermove + 빠른 펜 이동 시 점 간격이 16~30px 가 될 수 있음.
|
||
const MAX_GAP_PX = 8;
|
||
function pushPointWithInterp(target: Stroke, x: number, y: number, p: number) {
|
||
const last = target.points[target.points.length - 1];
|
||
if (last) {
|
||
const dx = x - last[0];
|
||
const dy = y - last[1];
|
||
const dist = Math.hypot(dx, dy);
|
||
if (dist > MAX_GAP_PX) {
|
||
const steps = Math.ceil(dist / MAX_GAP_PX);
|
||
const lp = last[2];
|
||
for (let i = 1; i < steps; i++) {
|
||
const t = i / steps;
|
||
target.points.push([
|
||
last[0] + dx * t,
|
||
last[1] + dy * t,
|
||
lp + (p - lp) * t,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
target.points.push([x, y, p]);
|
||
}
|
||
|
||
// 점 P 와 선분 [A, B] 사이 최단 거리의 제곱. 빠른 비교를 위해 sqrt 안 씀.
|
||
function distSqPointToSegment(
|
||
px: number, py: number,
|
||
ax: number, ay: number, bx: number, by: number,
|
||
): number {
|
||
const dx = bx - ax;
|
||
const dy = by - ay;
|
||
const lenSq = dx * dx + dy * dy;
|
||
if (lenSq === 0) {
|
||
const ex = px - ax;
|
||
const ey = py - ay;
|
||
return ex * ex + ey * ey;
|
||
}
|
||
let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
|
||
if (t < 0) t = 0;
|
||
else if (t > 1) t = 1;
|
||
const ex = px - (ax + t * dx);
|
||
const ey = py - (ay + t * dy);
|
||
return ex * ex + ey * ey;
|
||
}
|
||
|
||
// 지우개 이동 경로 [A, B] 위에 stroke 점이 eraserRadius 이내로 들어오면 해당
|
||
// stroke 통째 삭제 (object eraser). 단일 점 검사가 아니라 segment 검사라 빠른
|
||
// 지우개 이동에서 점 사이 stroke 누락 방지. A == B 면 단일 점 검사로 환원.
|
||
function eraseSegment(
|
||
x0: number, y0: number, x1: number, y1: 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;
|
||
for (const [px, py] of s.points) {
|
||
if (distSqPointToSegment(px, py, x0, y0, x1, y1) <= r2) {
|
||
hit = true;
|
||
break;
|
||
}
|
||
}
|
||
(hit ? removed : keep).push(s);
|
||
}
|
||
if (removed.length === 0) return false;
|
||
strokes = keep;
|
||
undoStack = [...undoStack, ...removed];
|
||
return true;
|
||
}
|
||
|
||
function onPointerDown(e: PointerEvent) {
|
||
if (DBG) dbg = { ...dbg, down: dbg.down + 1, lastType: e.pointerType, lastPressure: e.pressure };
|
||
if (!canvas) return;
|
||
if (!isPenLike(e)) {
|
||
if (DBG) dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1 };
|
||
return;
|
||
}
|
||
// pen 의 hover-down (buttons===0) 은 무시 — 실제 접촉이 아닌 hover 진입.
|
||
// Pencil 닿는 순간 buttons===1. 이걸 stroke 시작으로 오인하면 hover 이동이
|
||
// 점으로 추가됨.
|
||
if (e.pointerType === 'pen' && e.buttons === 0) {
|
||
if (DBG) dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1, lastType: 'hover-down' };
|
||
return;
|
||
}
|
||
// 이미 다른 pointer 가 그리는 중이면 무시 — palm rejection / multi-touch race 차단.
|
||
// (Apple Pencil 진행 중에 손가락 닿거나 두 번째 pen 시도하면 둘 다 망가짐)
|
||
if (isDrawing) return;
|
||
|
||
e.preventDefault();
|
||
try { document.getSelection?.()?.removeAllRanges?.(); } catch {}
|
||
|
||
// 이전 inflight 가 살아있다면 폐기 (다음 stroke 와 섞이지 않게).
|
||
inflight = null;
|
||
|
||
try { canvas.setPointerCapture(e.pointerId); } catch {}
|
||
activePointerId = e.pointerId;
|
||
isDrawing = true;
|
||
|
||
const [x, y] = getLocalXY(e);
|
||
|
||
if (tool === 'eraser') {
|
||
eraserLast = [x, y];
|
||
if (eraseSegment(x, y, x, y)) {
|
||
isDirty = true;
|
||
backup();
|
||
scheduleSave();
|
||
scheduleRedraw();
|
||
}
|
||
return;
|
||
}
|
||
|
||
inflight = {
|
||
id: `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||
points: [[x, y, normalizePressure(e.pressure)]],
|
||
};
|
||
scheduleRedraw();
|
||
}
|
||
|
||
function onPointerMove(e: PointerEvent) {
|
||
if (DBG) dbg = { ...dbg, move: dbg.move + 1, lastType: e.pointerType, lastPressure: e.pressure };
|
||
if (!isDrawing) return;
|
||
if (!isPenLike(e)) {
|
||
if (DBG) dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1 };
|
||
return;
|
||
}
|
||
if (e.pointerId !== activePointerId) {
|
||
if (DBG) dbg = { ...dbg, rejectedByPointerId: dbg.rejectedByPointerId + 1 };
|
||
return;
|
||
}
|
||
|
||
// ── Apple Pencil hover 감지 (iPadOS 17+) ─────────────────────────
|
||
// 펜이 화면에서 떨어진 채로도 pointermove 가 발화 — pointerType==='pen' 이지만
|
||
// buttons===0 (펜 안 닿음). pointerup 안 와도 hover 모드는 사실상 stroke 종료.
|
||
// 이 케이스를 잡지 않으면 hover 이동이 stroke 의 점으로 추가됨 → ㄱ 다음에
|
||
// ㅏ 위치로 hover 이동 시 ㄱ 끝점에서 ㅏ 위치까지 직선이 그어지거나 한 stroke
|
||
// 가 의도치 않게 연장됨 (사용자 보고: ㄱ→ㅏ 가 안 써짐, 글씨 흩어짐).
|
||
if (e.pointerType === 'pen' && e.buttons === 0) {
|
||
try { canvas?.releasePointerCapture?.(e.pointerId); } catch {}
|
||
activePointerId = null;
|
||
isDrawing = false;
|
||
if (DBG) dbg = { ...dbg, up: dbg.up + 1, lastType: 'hover-end' };
|
||
if (tool === 'eraser') {
|
||
inflight = null;
|
||
eraserLast = null;
|
||
return;
|
||
}
|
||
// pointerup 흐름: 1점 stroke (짧은 탭) 도 보존.
|
||
if (inflight && inflight.points.length >= 1) {
|
||
strokes = [...strokes, inflight];
|
||
undoStack = [];
|
||
isDirty = true;
|
||
backup();
|
||
scheduleSave();
|
||
}
|
||
inflight = null;
|
||
scheduleRedraw();
|
||
return;
|
||
}
|
||
|
||
const [x, y] = getLocalXY(e);
|
||
|
||
if (tool === 'eraser') {
|
||
const prev = eraserLast ?? [x, y];
|
||
if (eraseSegment(prev[0], prev[1], x, y)) {
|
||
isDirty = true;
|
||
backup();
|
||
scheduleSave();
|
||
scheduleRedraw();
|
||
}
|
||
eraserLast = [x, y];
|
||
return;
|
||
}
|
||
|
||
if (!inflight) return;
|
||
|
||
// coalesced events: 빠른 필기에서 보간 점들 모두 반영. 추가로 점 사이 거리가
|
||
// 8px 초과 시 자동 중간 점 보간 → 점선 방지.
|
||
const coalesced = (e.getCoalescedEvents?.() ?? []) as PointerEvent[];
|
||
if (DBG) dbg = { ...dbg, coalesced: dbg.coalesced + coalesced.length };
|
||
if (coalesced.length > 0) {
|
||
for (const ev of coalesced) {
|
||
const [cx, cy] = getLocalXY(ev);
|
||
pushPointWithInterp(inflight, cx, cy, normalizePressure(ev.pressure));
|
||
}
|
||
} else {
|
||
pushPointWithInterp(inflight, x, y, normalizePressure(e.pressure));
|
||
}
|
||
scheduleRedraw();
|
||
}
|
||
|
||
// pointerup → 정상 finalize. pointercancel → inflight 폐기 (사용자 의도가 아닌
|
||
// OS 강제 취소 — multi-touch / 시스템 gesture 인식 시 발생. cancel 된 stroke 가
|
||
// strokes 에 들어가면 의도치 않은 짧은 노이즈 stroke 누적, 사용자 글자 망가짐).
|
||
// pointerleave 는 안전망 — capture 가 정상 잡혀 있으면 leave 자체가 사양상 안
|
||
// 오므로 무해. 만약 leave 가 도착했다면 iOS Safari 가 capture 를 silently 풀어
|
||
// pointerup 이 캔버스에 routing 안 된 케이스 → 이 분기에서 강제 finalize 해야
|
||
// isDrawing 락이 풀려서 다음 stroke 가 막히지 않는다 (ㄱ → ㅏ hang 회귀 방어).
|
||
function endStroke(e: PointerEvent) {
|
||
if (DBG) {
|
||
if (e.type === 'pointerup') dbg = { ...dbg, up: dbg.up + 1 };
|
||
else if (e.type === 'pointercancel') dbg = { ...dbg, cancel: dbg.cancel + 1 };
|
||
else if (e.type === 'pointerleave') dbg = { ...dbg, leave: dbg.leave + 1 };
|
||
}
|
||
// pointerleave 의 capture 활성 가드는 DBG 와 무관하게 적용.
|
||
if (e.type === 'pointerleave' && canvas?.hasPointerCapture?.(e.pointerId)) return;
|
||
|
||
if (!isDrawing) return;
|
||
if (e.pointerId !== activePointerId) {
|
||
if (DBG) dbg = { ...dbg, rejectedByPointerId: dbg.rejectedByPointerId + 1 };
|
||
return;
|
||
}
|
||
|
||
try { canvas?.releasePointerCapture?.(e.pointerId); } catch {}
|
||
|
||
activePointerId = null;
|
||
isDrawing = false;
|
||
const wasCanceled = e.type === 'pointercancel';
|
||
|
||
if (tool === 'eraser') {
|
||
inflight = null;
|
||
eraserLast = null;
|
||
return;
|
||
}
|
||
|
||
// pointercancel 은 stroke 폐기.
|
||
if (wasCanceled) {
|
||
inflight = null;
|
||
scheduleRedraw();
|
||
return;
|
||
}
|
||
|
||
// pointerup: 정상 종료 — 1점 stroke (짧은 탭) 도 보존.
|
||
if (inflight && inflight.points.length >= 1) {
|
||
strokes = [...strokes, inflight];
|
||
undoStack = [];
|
||
isDirty = true;
|
||
backup();
|
||
scheduleSave();
|
||
}
|
||
inflight = null;
|
||
scheduleRedraw();
|
||
}
|
||
|
||
// ── 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;
|
||
backup();
|
||
scheduleSave();
|
||
scheduleRedraw();
|
||
}
|
||
function redo() {
|
||
if (undoStack.length === 0) return;
|
||
const last = undoStack[undoStack.length - 1];
|
||
undoStack = undoStack.slice(0, -1);
|
||
strokes = [...strokes, last];
|
||
isDirty = true;
|
||
backup();
|
||
scheduleSave();
|
||
scheduleRedraw();
|
||
}
|
||
function clearAll() {
|
||
if (strokes.length === 0) return;
|
||
if (!confirm('이 세션의 모든 stroke 를 지웁니다. 계속할까요?')) return;
|
||
undoStack = [...undoStack, ...strokes];
|
||
strokes = [];
|
||
isDirty = true;
|
||
backup();
|
||
scheduleSave();
|
||
scheduleRedraw();
|
||
}
|
||
|
||
// ── autosave: 입력 루프와 완전 분리 ──
|
||
// 3초 idle debounce. flushSave 는 setTimeout 0 으로 다음 macrotask 에 ship.
|
||
// PATCH 응답이 늦게 오더라도 strokes 배열을 덮어쓰지 않음 (응답 무시).
|
||
function scheduleSave() {
|
||
if (saveTimer) clearTimeout(saveTimer);
|
||
saveTimer = window.setTimeout(flushSave, 3000) as unknown as number;
|
||
}
|
||
function flushSave() {
|
||
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
|
||
if (!isDirty) return;
|
||
isDirty = false;
|
||
// 런타임 캐시 제외 — 서버 PATCH 페이로드에 Path2D 직렬화 cruft 들어가지 않도록.
|
||
const snapshot = {
|
||
version: 1 as const,
|
||
strokes: serializableStrokes() as Stroke[],
|
||
};
|
||
setTimeout(() => onChange?.(snapshot), 0);
|
||
}
|
||
|
||
// ── snapshot (PNG) ──
|
||
async function takeSnapshot() {
|
||
if (!canvas || !onSnapshot) return;
|
||
if (snapshotting) return;
|
||
snapshotting = true;
|
||
snapshotErr = null;
|
||
try {
|
||
redraw();
|
||
flushSave();
|
||
const blob = await new Promise<Blob | null>((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();
|
||
if (backupTimer) flushBackup(); // pending debounced backup 강제 실행.
|
||
}
|
||
|
||
// ── 마운트 / cleanup ──
|
||
let resizeObserver: ResizeObserver | null = null;
|
||
function onWindowResize() { resizeCanvas(); }
|
||
|
||
// window 레벨 안전망 — iOS Safari 가 setPointerCapture 를 silently 풀어
|
||
// pointerup 이 캔버스 element 에 routing 안 되는 케이스 (특히 펜이 캔버스 영역
|
||
// *안*에서 hover 해제될 때 pointerleave 도 미발화) 를 cover. window 에는
|
||
// 거의 항상 도달하므로 isDrawing 락이 영구 풀린다 → ㄱ→ㅏ hang 회귀 차단.
|
||
function onWindowPointerEnd(e: PointerEvent) {
|
||
if (!isDrawing) return;
|
||
if (e.pointerId !== activePointerId) return;
|
||
endStroke(e);
|
||
}
|
||
|
||
onMount(() => {
|
||
try {
|
||
const txt = getComputedStyle(document.documentElement).getPropertyValue('--text').trim();
|
||
if (txt) strokeColor = txt;
|
||
} catch {}
|
||
resizeCanvas();
|
||
restoreFromLocalStorageIfNewer();
|
||
requestAnimationFrame(() => requestAnimationFrame(resizeCanvas));
|
||
|
||
if (containerEl && 'ResizeObserver' in window) {
|
||
resizeObserver = new ResizeObserver(() => resizeCanvas());
|
||
resizeObserver.observe(containerEl);
|
||
}
|
||
window.addEventListener('resize', onWindowResize);
|
||
window.addEventListener('orientationchange', onWindowResize);
|
||
window.addEventListener('beforeunload', onBeforeUnload);
|
||
window.addEventListener('pointerup', onWindowPointerEnd);
|
||
window.addEventListener('pointercancel', onWindowPointerEnd);
|
||
});
|
||
onDestroy(() => {
|
||
flushSave();
|
||
if (backupTimer) flushBackup();
|
||
resizeObserver?.disconnect();
|
||
if (typeof window !== 'undefined') {
|
||
window.removeEventListener('resize', onWindowResize);
|
||
window.removeEventListener('orientationchange', onWindowResize);
|
||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||
window.removeEventListener('pointerup', onWindowPointerEnd);
|
||
window.removeEventListener('pointercancel', onWindowPointerEnd);
|
||
}
|
||
});
|
||
|
||
// traceText 변경 시만 redraw (의존성 명시)
|
||
let _prevTraceText = $state(traceText);
|
||
$effect(() => {
|
||
if (traceText !== _prevTraceText) {
|
||
_prevTraceText = traceText;
|
||
scheduleRedraw();
|
||
}
|
||
});
|
||
|
||
// ── Safari/Chrome hardening for buttons ──
|
||
const BTN_STYLE =
|
||
'touch-action: manipulation; user-select: none; -webkit-user-select: none; ' +
|
||
'-webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;';
|
||
|
||
// iOS Safari 의 button focus 는 mousedown/pointerdown 단계에 발동 → 그 영역으로
|
||
// 자동 zoom (사용자 보고: "펜/지우개 누르면 해당 부분 확대"). click 시점의
|
||
// clickThenBlur 는 이미 늦음 — focus 가 잡히는 시점이 mousedown 이라 그 단계에서
|
||
// preventDefault 해야 focus 자체가 안 잡힘. click 이벤트는 별도라 onclick 정상.
|
||
function blockBtnFocus(e: Event) { e.preventDefault(); }
|
||
|
||
// (clickThenBlur 는 잔존 케이스 안전망으로 유지 — focus 가 어떻게든 잡힌
|
||
// 케이스에서 즉시 blur. mousedown 차단이 1차, blur 가 2차.)
|
||
function clickThenBlur(fn: () => void) {
|
||
return (e: Event) => {
|
||
const t = e?.currentTarget as HTMLElement | null;
|
||
if (t?.blur) t.blur();
|
||
fn();
|
||
};
|
||
}
|
||
</script>
|
||
|
||
<div class="flex flex-col h-full" style="touch-action: manipulation;">
|
||
<!-- 툴바 -->
|
||
<div
|
||
class="flex items-center gap-1 px-2 py-1 border-b border-default bg-surface shrink-0 flex-wrap"
|
||
style="touch-action: manipulation; user-select: none; -webkit-user-select: none;"
|
||
>
|
||
<button
|
||
type="button"
|
||
onmousedown={blockBtnFocus}
|
||
onpointerdown={blockBtnFocus}
|
||
onclick={clickThenBlur(() => (tool = 'pen'))}
|
||
style={BTN_STYLE}
|
||
tabindex={-1}
|
||
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"
|
||
onmousedown={blockBtnFocus}
|
||
onpointerdown={blockBtnFocus}
|
||
onclick={clickThenBlur(() => (tool = 'eraser'))}
|
||
style={BTN_STYLE}
|
||
tabindex={-1}
|
||
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>
|
||
|
||
{#each [['thin', '가늘게'], ['normal', '보통'], ['thick', '굵게']] as [w, label]}
|
||
<button
|
||
type="button"
|
||
onmousedown={blockBtnFocus}
|
||
onpointerdown={blockBtnFocus}
|
||
onclick={clickThenBlur(() => (widthMode = w))}
|
||
style={BTN_STYLE}
|
||
tabindex={-1}
|
||
class="px-2 py-1 rounded text-xs transition-colors
|
||
{widthMode === w ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface'}"
|
||
aria-pressed={widthMode === w}
|
||
>{label}</button>
|
||
{/each}
|
||
|
||
<span class="w-px h-5 bg-default mx-1" aria-hidden></span>
|
||
|
||
<IconButton icon={Undo2} size="sm" aria-label="실행 취소" onclick={clickThenBlur(undo)} disabled={strokes.length === 0} style={BTN_STYLE} tabindex={-1} onmousedown={blockBtnFocus} onpointerdown={blockBtnFocus} />
|
||
<IconButton icon={Redo2} size="sm" aria-label="다시 실행" onclick={clickThenBlur(redo)} disabled={undoStack.length === 0} style={BTN_STYLE} tabindex={-1} onmousedown={blockBtnFocus} onpointerdown={blockBtnFocus} />
|
||
<IconButton icon={Trash2} size="sm" aria-label="모두 지우기" onclick={clickThenBlur(clearAll)} disabled={strokes.length === 0} style={BTN_STYLE} tabindex={-1} onmousedown={blockBtnFocus} onpointerdown={blockBtnFocus} />
|
||
|
||
<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"
|
||
onmousedown={blockBtnFocus}
|
||
onpointerdown={blockBtnFocus}
|
||
onclick={clickThenBlur(takeSnapshot)}
|
||
disabled={snapshotting || strokes.length === 0}
|
||
style={BTN_STYLE}
|
||
tabindex={-1}
|
||
class="px-3 py-1 rounded text-sm bg-accent text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{snapshotting ? '저장 중...' : 'PNG 저장'}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 캔버스 컨테이너 -->
|
||
<div
|
||
bind:this={containerEl}
|
||
class="flex-1 min-h-0 bg-bg relative overflow-hidden"
|
||
style="user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; touch-action: none;"
|
||
>
|
||
<canvas
|
||
bind:this={canvas}
|
||
onpointerdown={onPointerDown}
|
||
onpointermove={onPointerMove}
|
||
onpointerup={endStroke}
|
||
onpointercancel={endStroke}
|
||
onpointerleave={endStroke}
|
||
oncontextmenu={(e) => e.preventDefault()}
|
||
onselectstart={(e) => e.preventDefault()}
|
||
class="block"
|
||
style="touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; cursor: {tool === 'eraser' ? 'cell' : 'crosshair'};"
|
||
></canvas>
|
||
|
||
{#if DBG}
|
||
<!-- 라이브 디버그 패널 — DEV 빌드 또는 prod 에서 ?debug=1 query 시 활성. -->
|
||
<div class="absolute top-1 left-1 px-2 py-1 rounded bg-bg/90 text-[10px] text-dim font-mono pointer-events-none leading-tight">
|
||
type:{dbg.lastType} p:{dbg.lastPressure.toFixed(2)}<br/>
|
||
down:{dbg.down} move:{dbg.move} up:{dbg.up} cancel:{dbg.cancel}<br/>
|
||
rejType:{dbg.rejectedByType} rejId:{dbg.rejectedByPointerId} coal:{dbg.coalesced}<br/>
|
||
drawing:{isDrawing ? 'Y' : 'N'} actId:{activePointerId ?? '-'} infPts:{inflight?.points.length ?? 0}<br/>
|
||
strokes:{strokes.length}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|