Files
hyungi_document_server/frontend/src/lib/components/HandwriteCanvas.svelte
T
Hyungi Ahn b45091c8cb fix(study): 펜/지우개 버튼 focus zoom — mousedown/pointerdown 단계 차단
사용자 보고: "펜이나 지우개를 누르면 자동으로 해당 부분 확대". 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>
2026-04-27 13:14:01 +09:00

767 lines
29 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 필기 엔진 (전면 재작성).
*
* 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>