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:
Hyungi Ahn
2026-04-27 11:08:36 +09:00
parent fd507bf9fd
commit aa2ff7d4bc
+125 -182
View File
@@ -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>