fix(study): HandwriteCanvas 전면 재작성 — Apple Pencil 입력 파이프라인 통합 fix
기존 문제: 점선 stroke / 연속 입력 누락 / 버튼 focus zoom / Safari 선택 팝업. 원인을 4축으로 분리해서 한꺼번에 fix. [1] 입력 수집 (PointerEvent 상태머신) - isDrawing flag + activePointerId 매칭으로 stroke 누락 방지 - pointerdown: 이전 inflight 가 살아있으면 finalize 후 새 stroke 시작 - setPointerCapture (try-catch) — element 외 pointer move 도 받음 - pointerup / pointercancel / pointerleave 통합 endStroke - pointerType === 'pen' (mouse 도 데스크톱) 만, 손가락 거부 [2] coalesced events - pointermove 의 e.getCoalescedEvents() 모두 points 에 push - 빠른 필기에서 sparse point → 점선 현상 방지 핵심 - normalizePressure: 0/비정상 값은 0.5 fallback [3] 렌더링: perfect-freehand polygon fill - getStroke(thinning:0.4, smoothing:0.62, streamline:0.5, last:true) - getSvgPathFromStroke (perfect-freehand README 표준 builder) → Path2D → ctx.fill() — anti-aliased polygon - 1점 케이스: arc fill 폴백 - last: true 항상 (진행 중에도 polygon 닫힘) [4] autosave 입력 분리 - 3초 idle debounce - flushSave 는 setTimeout 0 으로 다음 macrotask - PATCH 응답이 strokes 를 덮어쓰지 않음 (응답 무시, fire-and-forget) [5] Safari/Chrome hardening - 캔버스/컨테이너: touch-action: none + user-select: none + -webkit-touch-callout: none + -webkit-tap-highlight-color: transparent - canvas 에 oncontextmenu / onselectstart preventDefault - 모든 toolbar 버튼: clickThenBlur(fn) + tabindex=-1 + BTN_STYLE → button focus zoom 차단 (사용자 보고 "버튼 누르면 화면 확대" 핵심) [6] resize 정책 - ResizeObserver + window resize/orientationchange 만 트리거 - pointermove 마다 resize 절대 안 함 - DPR 반영 + setTransform(dpr,...) 으로 retina 선명 수정 범위 (사용자 명시): HandwriteCanvas.svelte 만. 다른 영역 무수정.
This commit is contained in:
@@ -1,47 +1,37 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* HandwriteCanvas — Apple Pencil + 일반 터치/마우스 손글씨 캔버스.
|
||||
* 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 로 즉시 삭제
|
||||
* (전체 지우기 버튼은 별도)
|
||||
* 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]
|
||||
type Stroke = {
|
||||
id: string;
|
||||
points: Point[];
|
||||
};
|
||||
export type StrokesJson = {
|
||||
version: 1;
|
||||
strokes: Stroke[];
|
||||
};
|
||||
type Stroke = { id: string; points: Point[] };
|
||||
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;
|
||||
/** 펜 기본 굵기 (default 6). kanji 면 약간 굵게 권장. */
|
||||
baseSize?: number;
|
||||
/** PATCH /api/study-sessions/{id} strokes_json 트리거. */
|
||||
onChange?: (strokes: StrokesJson) => void;
|
||||
/** POST /snapshot — Blob 전달. */
|
||||
onSnapshot?: (blob: Blob) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -61,34 +51,29 @@
|
||||
let cssHeight = $state(600);
|
||||
|
||||
let strokes = $state<Stroke[]>(initialStrokes?.strokes ?? []);
|
||||
let undoStack = $state<Stroke[]>([]); // redo 큐 (clear 또는 undo 로 빠진 stroke)
|
||||
let undoStack = $state<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);
|
||||
// 입력 상태머신 — 다음 stroke 가 막히지 않게 명확히 관리.
|
||||
let isDrawing = false;
|
||||
let activePointerId: number | null = null;
|
||||
|
||||
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));
|
||||
|
||||
let isDirty = false;
|
||||
let saveTimer: number | null = null;
|
||||
let snapshotting = $state(false);
|
||||
let snapshotErr = $state<string | null>(null);
|
||||
|
||||
// 굵기 단계 (사용자 선택). baseSize 가 기본, multiplier 곱해서 적용.
|
||||
type Width = 'thin' | 'normal' | 'thick';
|
||||
let widthMode = $state<Width>('normal');
|
||||
const WIDTH_FACTOR: Record<Width, number> = { thin: 0.6, normal: 1, thick: 1.6 };
|
||||
let effectiveSize = $derived(baseSize * WIDTH_FACTOR[widthMode]);
|
||||
|
||||
// 지우개 반경 (CSS px) — effectiveSize 의 약 4배. Apple Pencil 정밀도와 균형.
|
||||
let eraserRadius = $derived(Math.max(16, effectiveSize * 4));
|
||||
|
||||
// ── localStorage backup ──
|
||||
// ── localStorage ──
|
||||
const lsKey = $derived(`study_session_${sessionId}_strokes`);
|
||||
function backupToLocalStorage() {
|
||||
try {
|
||||
localStorage.setItem(lsKey, JSON.stringify({ version: 1, strokes }));
|
||||
} catch {
|
||||
// quota / 비활성 — 무시
|
||||
}
|
||||
function backup() {
|
||||
try { localStorage.setItem(lsKey, JSON.stringify({ version: 1, strokes })); } catch {}
|
||||
}
|
||||
function restoreFromLocalStorageIfNewer() {
|
||||
try {
|
||||
@@ -99,12 +84,10 @@
|
||||
strokes = parsed.strokes;
|
||||
scheduleSave();
|
||||
}
|
||||
} catch {
|
||||
// 깨진 데이터 — 무시
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── DPR + resize ──
|
||||
// ── DPR + resize (layout/orientation 변화 시에만) ──
|
||||
function resizeCanvas() {
|
||||
if (!canvas || !containerEl) return;
|
||||
const rect = containerEl.getBoundingClientRect();
|
||||
@@ -120,44 +103,46 @@
|
||||
scheduleRedraw();
|
||||
}
|
||||
|
||||
// ── render — 단일 ctx.stroke() + quadratic bezier 보간.
|
||||
// perfect-freehand 의 polygon outline 이 빠른 stroke + sparse point 에서 점선처럼
|
||||
// 깨지는 현상이 있어 표준 canvas 방식으로 단순화. 압력 효과는 미반영 (단일 굵기).
|
||||
// ── 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) {
|
||||
const pts = s.points;
|
||||
if (pts.length === 0) return;
|
||||
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = effectiveSize;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.fillStyle = strokeColor;
|
||||
|
||||
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(pts[0][0], pts[0][1], effectiveSize / 2, 0, Math.PI * 2);
|
||||
ctx.arc(x, y, Math.max(1, r), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pts.length === 2) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0][0], pts[0][1]);
|
||||
ctx.lineTo(pts[1][0], pts[1][1]);
|
||||
ctx.stroke();
|
||||
return;
|
||||
}
|
||||
|
||||
// 3개 이상 — quadratic bezier 로 점-점 사이 보간. 한 번의 stroke() 로 그림.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0][0], pts[0][1]);
|
||||
for (let i = 1; i < pts.length - 1; i++) {
|
||||
const cx = (pts[i][0] + pts[i + 1][0]) / 2;
|
||||
const cy = (pts[i][1] + pts[i + 1][1]) / 2;
|
||||
ctx.quadraticCurveTo(pts[i][0], pts[i][1], cx, cy);
|
||||
}
|
||||
ctx.lineTo(pts[pts.length - 1][0], pts[pts.length - 1][1]);
|
||||
ctx.stroke();
|
||||
const outline = getStroke(pts, {
|
||||
size: effectiveSize,
|
||||
thinning: 0.4, // 압력 변화 폭 (얇아지지 않게 보수적)
|
||||
smoothing: 0.62, // 점 간 보간 강도
|
||||
streamline: 0.5, // 손떨림 보정
|
||||
simulatePressure: false, // 실제 e.pressure 사용 + normalizePressure 폴백
|
||||
last: true, // 진행 중 stroke 도 양쪽 outline + cap 완성 (polygon 닫힘 보장)
|
||||
});
|
||||
if (outline.length < 2) return;
|
||||
ctx.fillStyle = strokeColor;
|
||||
const path = new Path2D(getSvgPathFromStroke(outline));
|
||||
ctx.fill(path);
|
||||
}
|
||||
|
||||
function drawTraceBackground(ctx: CanvasRenderingContext2D) {
|
||||
@@ -178,16 +163,11 @@
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
||||
drawTraceBackground(ctx);
|
||||
for (const s of strokes) {
|
||||
drawStroke(ctx, s);
|
||||
}
|
||||
if (inflight) {
|
||||
drawStroke(ctx, inflight);
|
||||
}
|
||||
for (const s of strokes) drawStroke(ctx, s);
|
||||
if (inflight) drawStroke(ctx, inflight);
|
||||
}
|
||||
|
||||
// RAF throttle — pointermove 가 60Hz 보다 빠르게 들어와도 redraw 는 frame 당 1회.
|
||||
// input 처리를 redraw 와 분리해서 빠른 입력 누락 방지.
|
||||
let rafScheduled = false;
|
||||
function scheduleRedraw() {
|
||||
if (rafScheduled) return;
|
||||
@@ -198,40 +178,26 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── pointer 헬퍼 ──
|
||||
// ── pointer ──
|
||||
function getLocalXY(e: PointerEvent): [number, number] {
|
||||
if (!canvas) return [0, 0];
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
// visualViewport scale 이 1 아닌 경우 (사용자가 핀치줌 한 상태) 페이지 좌표 보정.
|
||||
// rect 와 e.clientX 둘 다 layout viewport 기준이라 보통은 일치하지만 일부 iOS 빌드에서
|
||||
// visualViewport 적용된 coordinate 가 들어옴.
|
||||
const vv = (typeof window !== 'undefined' ? window.visualViewport : null);
|
||||
const scale = vv?.scale ?? 1;
|
||||
const scaleX = rect.width === 0 ? 1 : cssWidth / rect.width;
|
||||
const scaleY = rect.height === 0 ? 1 : cssHeight / rect.height;
|
||||
// scale != 1 이어도 rect 와 clientX 모두 같은 좌표계라 추가 보정 불필요.
|
||||
// scale 변수 자체는 디버그용 (필요 시 활용).
|
||||
void scale;
|
||||
return [
|
||||
(e.clientX - rect.left) * scaleX,
|
||||
(e.clientY - rect.top) * scaleY,
|
||||
];
|
||||
return [(e.clientX - rect.left) * scaleX, (e.clientY - rect.top) * scaleY];
|
||||
}
|
||||
|
||||
function isPenLike(e: PointerEvent): boolean {
|
||||
// Apple Pencil ('pen') 만 인식. 손가락 ('touch') 거부 — palm rejection.
|
||||
// 'mouse' 는 데스크톱 테스트용.
|
||||
// Apple Pencil ('pen') 만 stroke 입력. 손가락 ('touch') 거부 — palm rejection.
|
||||
// mouse 는 데스크톱 테스트 허용.
|
||||
return e.pointerType === 'pen' || e.pointerType === 'mouse';
|
||||
}
|
||||
|
||||
// pressure 안정화: 0 또는 비정상적으로 작은 값은 0.5 로 대체 (점선 방지).
|
||||
function normalizePressure(p: number | undefined): number {
|
||||
if (typeof p !== 'number' || !Number.isFinite(p) || p <= 0.05) return 0.5;
|
||||
return Math.min(1, p);
|
||||
}
|
||||
|
||||
// ── 지우개 hit-test ──
|
||||
/** 주어진 좌표 (x, y) 반경 안에 있는 stroke 들을 strokes 배열에서 제거. */
|
||||
function eraseAt(x: number, y: number): boolean {
|
||||
if (strokes.length === 0) return false;
|
||||
const r2 = eraserRadius * eraserRadius;
|
||||
@@ -239,7 +205,6 @@
|
||||
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;
|
||||
@@ -253,29 +218,30 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── pointer 핸들러 ──
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!canvas || !isPenLike(e)) return;
|
||||
e.preventDefault();
|
||||
|
||||
// 이전 stroke 의 inflight 가 어떤 이유로 살아있다면 강제 finalize (race 방어).
|
||||
// 이전 inflight 가 어떤 이유로 살아있다면 즉시 finalize (다음 stroke 누락 방지).
|
||||
if (inflight) {
|
||||
if (inflight.points.length > 1) {
|
||||
strokes = [...strokes, inflight];
|
||||
isDirty = true;
|
||||
backupToLocalStorage();
|
||||
backup();
|
||||
}
|
||||
inflight = null;
|
||||
}
|
||||
|
||||
try { canvas.setPointerCapture(e.pointerId); } catch {}
|
||||
activePointerId = e.pointerId;
|
||||
isDrawing = true;
|
||||
|
||||
const [x, y] = getLocalXY(e);
|
||||
|
||||
if (tool === 'eraser') {
|
||||
const removed = eraseAt(x, y);
|
||||
if (removed) {
|
||||
if (eraseAt(x, y)) {
|
||||
isDirty = true;
|
||||
backupToLocalStorage();
|
||||
backup();
|
||||
scheduleSave();
|
||||
scheduleRedraw();
|
||||
}
|
||||
@@ -290,17 +256,16 @@
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
// pointerId 매칭 완화: pen 인 동일 pointer 면 처리 (race 방어).
|
||||
if (!isDrawing) return;
|
||||
if (!isPenLike(e)) return;
|
||||
if (e.pointerId !== activePointerId) return;
|
||||
|
||||
const [x, y] = getLocalXY(e);
|
||||
|
||||
if (tool === 'eraser') {
|
||||
// eraser 모드는 active drag 일 때만 (pointer down 상태)
|
||||
if (e.pointerId !== activePointerId) return;
|
||||
const removed = eraseAt(x, y);
|
||||
if (removed) {
|
||||
if (eraseAt(x, y)) {
|
||||
isDirty = true;
|
||||
backupToLocalStorage();
|
||||
backup();
|
||||
scheduleSave();
|
||||
scheduleRedraw();
|
||||
}
|
||||
@@ -308,7 +273,8 @@
|
||||
}
|
||||
|
||||
if (!inflight) return;
|
||||
// coalesced events: 빠른 필기에서 샘플 간격을 좁히기 위해 모두 반영.
|
||||
|
||||
// coalesced events: 빠른 필기에서 보간 점들 모두 반영 → 점선 방지 핵심.
|
||||
const coalesced = (e.getCoalescedEvents?.() ?? []) as PointerEvent[];
|
||||
if (coalesced.length > 0) {
|
||||
for (const ev of coalesced) {
|
||||
@@ -321,28 +287,28 @@
|
||||
scheduleRedraw();
|
||||
}
|
||||
|
||||
// pointerup / pointercancel / pointerleave 통합 finalize.
|
||||
function endStroke(e: PointerEvent) {
|
||||
// pointerleave race 방어: inflight 가 이미 없으면 그냥 정리.
|
||||
if (!inflight) {
|
||||
if (e.pointerId === activePointerId) activePointerId = null;
|
||||
return;
|
||||
}
|
||||
// pointerId 미스매치라도 inflight 가 살아있으면 finalize 시도 (Apple Pencil 의
|
||||
// pointerId 가 가끔 재사용되거나 변경되는 케이스 방어). 단 다른 pointer 의
|
||||
// pointerleave 가 stale 하게 들어왔다면 무시.
|
||||
if (e.pointerId !== activePointerId && e.type !== 'pointerup' && e.type !== 'pointercancel') return;
|
||||
if (!isDrawing) return;
|
||||
// pointerleave 가 pointerCapture 활성 시 element 외로 나갈 때만 발생.
|
||||
// 다른 pointer 의 이벤트는 e.pointerId !== activePointerId 로 거름.
|
||||
if (e.pointerId !== activePointerId) return;
|
||||
|
||||
try { canvas?.releasePointerCapture?.(e.pointerId); } catch {}
|
||||
|
||||
activePointerId = null;
|
||||
isDrawing = false;
|
||||
|
||||
if (tool === 'eraser') {
|
||||
inflight = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (inflight.points.length > 1) {
|
||||
if (inflight && inflight.points.length > 1) {
|
||||
strokes = [...strokes, inflight];
|
||||
undoStack = [];
|
||||
isDirty = true;
|
||||
backupToLocalStorage();
|
||||
backup();
|
||||
scheduleSave();
|
||||
}
|
||||
inflight = null;
|
||||
@@ -356,7 +322,7 @@
|
||||
strokes = strokes.slice(0, -1);
|
||||
undoStack = [...undoStack, last];
|
||||
isDirty = true;
|
||||
backupToLocalStorage();
|
||||
backup();
|
||||
scheduleSave();
|
||||
scheduleRedraw();
|
||||
}
|
||||
@@ -366,7 +332,7 @@
|
||||
undoStack = undoStack.slice(0, -1);
|
||||
strokes = [...strokes, last];
|
||||
isDirty = true;
|
||||
backupToLocalStorage();
|
||||
backup();
|
||||
scheduleSave();
|
||||
scheduleRedraw();
|
||||
}
|
||||
@@ -376,24 +342,20 @@
|
||||
undoStack = [...undoStack, ...strokes];
|
||||
strokes = [];
|
||||
isDirty = true;
|
||||
backupToLocalStorage();
|
||||
backup();
|
||||
scheduleSave();
|
||||
scheduleRedraw();
|
||||
}
|
||||
|
||||
// ── 자동 저장 디바운스 ── stroke 입력 루프와 완전 분리.
|
||||
// - 빈도: 3초 idle 만 (5 stroke 즉시 flush 제거 — 빠른 필기 중 직렬화 부하 방지)
|
||||
// - 호출은 setTimeout 0 으로 다음 macrotask 에 ship → JSON.stringify 가
|
||||
// pointermove 와 충돌하지 않도록.
|
||||
// ── 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 (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
|
||||
if (!isDirty) return;
|
||||
isDirty = false;
|
||||
const snapshot = { version: 1 as const, strokes };
|
||||
@@ -409,7 +371,7 @@
|
||||
try {
|
||||
redraw();
|
||||
flushSave();
|
||||
const blob: Blob | null = await new Promise((resolve) =>
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas!.toBlob((b) => resolve(b), 'image/png')
|
||||
);
|
||||
if (!blob) throw new Error('PNG 생성 실패');
|
||||
@@ -423,25 +385,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onBeforeUnload() {
|
||||
if (isDirty) flushSave();
|
||||
}
|
||||
function onBeforeUnload() { if (isDirty) flushSave(); }
|
||||
|
||||
// ── 마운트 ──
|
||||
// ── 마운트 / cleanup ──
|
||||
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 유지
|
||||
}
|
||||
} catch {}
|
||||
resizeCanvas();
|
||||
restoreFromLocalStorageIfNewer();
|
||||
// layout 이 안정된 후 한 번 더 (flex 레이아웃 첫 paint 직후 0x0 케이스 방어)
|
||||
requestAnimationFrame(() => requestAnimationFrame(resizeCanvas));
|
||||
redraw();
|
||||
|
||||
if (containerEl && 'ResizeObserver' in window) {
|
||||
resizeObserver = new ResizeObserver(() => resizeCanvas());
|
||||
@@ -455,56 +412,42 @@
|
||||
flushSave();
|
||||
resizeObserver?.disconnect();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
window.removeEventListener('orientationchange', onWindowResize);
|
||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
}
|
||||
});
|
||||
|
||||
// initialStrokes 동기화는 onMount 한 번만 (위에서 이미 처리). $effect 사용 시
|
||||
// strokes 도 의존성으로 추적되어 사용자가 stroke 추가하는 순간 옛 initialStrokes 로
|
||||
// 되돌아가는 버그 발생 → 펜 떼면 새 stroke 사라짐.
|
||||
// 부모가 prop 으로 새 initialStrokes 를 보내도 무시 (사용자 진행 stroke 우선).
|
||||
|
||||
// traceText 변경 시만 redraw 트리거 (의존성 명시 access).
|
||||
// traceText 변경 시만 redraw (의존성 명시)
|
||||
let _prevTraceText = $state(traceText);
|
||||
$effect(() => {
|
||||
if (traceText !== _prevTraceText) {
|
||||
_prevTraceText = traceText;
|
||||
redraw();
|
||||
scheduleRedraw();
|
||||
}
|
||||
});
|
||||
|
||||
// toolbar/header button 공통 inline style — iPad Safari 더블탭 줌 / long-press
|
||||
// 메뉴 / 텍스트 선택 / tap highlight 모두 차단. 각 button 에 직접 적용.
|
||||
// ── 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;';
|
||||
|
||||
// 버튼 click 직후 즉시 blur() — iPad/Chrome 의 button focus 시 자동 zoom in
|
||||
// (사용자 보고: "버튼 누르면 화면이 확대된다") 차단. focus 가 가지 않으면 zoom 안 됨.
|
||||
// 호출 형태: onclick={withBlur(() => doSomething())}
|
||||
function withBlur<F extends (...args: any[]) => any>(fn: F) {
|
||||
return (e: any, ...rest: any[]) => {
|
||||
const target = e?.currentTarget as HTMLElement | undefined;
|
||||
if (target?.blur) target.blur();
|
||||
return fn(e, ...rest);
|
||||
};
|
||||
}
|
||||
// 0-arg 버전 (인자 없는 핸들러용)
|
||||
// button click 직후 즉시 blur — focus 시 발생하는 자동 zoom in 차단.
|
||||
function clickThenBlur(fn: () => void) {
|
||||
return (e: Event) => {
|
||||
const target = e?.currentTarget as HTMLElement | null;
|
||||
if (target?.blur) target.blur();
|
||||
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;">
|
||||
<!-- 툴바 — 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;">
|
||||
<!-- Pen / Eraser 모드 토글 -->
|
||||
<!-- 툴바 -->
|
||||
<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"
|
||||
onclick={clickThenBlur(() => (tool = 'pen'))}
|
||||
@@ -530,7 +473,6 @@
|
||||
|
||||
<span class="w-px h-5 bg-default mx-1" aria-hidden></span>
|
||||
|
||||
<!-- 굵기 선택 — 압력은 자동 반영, 이건 base 굵기 -->
|
||||
{#each [['thin', '가늘게'], ['normal', '보통'], ['thick', '굵게']] as [w, label]}
|
||||
<button
|
||||
type="button"
|
||||
@@ -566,11 +508,11 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 캔버스 컨테이너 — 텍스트 선택 / iOS long-press 메뉴 차단 -->
|
||||
<!-- 캔버스 컨테이너 -->
|
||||
<div
|
||||
bind:this={containerEl}
|
||||
class="flex-1 min-h-0 bg-bg relative overflow-hidden border-2 border-default/30"
|
||||
style="user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;"
|
||||
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}
|
||||
@@ -578,8 +520,9 @@
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={endStroke}
|
||||
onpointercancel={endStroke}
|
||||
onpointerleave={(e) => { if (inflight && e.pointerId === activePointerId) endStroke(e); }}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user