diff --git a/frontend/src/lib/components/HandwriteCanvas.svelte b/frontend/src/lib/components/HandwriteCanvas.svelte index 4326fd1..9049c2b 100644 --- a/frontend/src/lib/components/HandwriteCanvas.svelte +++ b/frontend/src/lib/components/HandwriteCanvas.svelte @@ -121,39 +121,48 @@ redraw(); } - // ── render — perfect-freehand polygon outline + bezier 보간으로 Notability 수준 필기감. - // 알고리즘: - // 1) getStroke() → 압력/속도 기반 polygon outline points - // 2) outline 을 quadratic bezier 로 연결 → 부드러운 곡선 - // 3) ctx.fill() — anti-aliased polygon + // ── render — perfect-freehand 표준 getSvgPathFromStroke + Path2D fill. + // 빠른 필기에서도 점선처럼 끊기지 않고 polygon 으로 fill. 빈 점도 점-원 폴백. + function getSvgPathFromStroke(stroke: number[][]): string { + if (!stroke.length) return ''; + const d: (string | number)[] = ['M', stroke[0][0], stroke[0][1], '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) { if (s.points.length === 0) return; + // 점이 1개만 있을 때 (탭) 작은 원으로 그려도 polygon 비어있음 방지 + if (s.points.length === 1) { + const [x, y, p] = s.points[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; + } + const outline = getStroke(s.points, { size: effectiveSize, - thinning: 0.6, // 압력 변화 폭. 강할수록 압력 차이 진함. - smoothing: 0.65, // 점 보간 강도 - streamline: 0.5, // stroke 안정화 (떨림 감소) - simulatePressure: false, // 실제 e.pressure 사용 + thinning: 0.5, + smoothing: 0.7, + streamline: 0.55, + simulatePressure: false, // 실제 e.pressure (onPointerDown/Move 에서 0.5 fallback 적용) last: true, - easing: (t) => t, }); if (outline.length < 2) return; ctx.fillStyle = strokeColor; - ctx.beginPath(); - ctx.moveTo(outline[0][0], outline[0][1]); - for (let i = 1; i < outline.length - 1; i++) { - const [x1, y1] = outline[i]; - const [x2, y2] = outline[i + 1]; - const mx = (x1 + x2) / 2; - const my = (y1 + y2) / 2; - ctx.quadraticCurveTo(x1, y1, mx, my); - } - const last = outline[outline.length - 1]; - ctx.lineTo(last[0], last[1]); - ctx.closePath(); - ctx.fill(); + const pathStr = getSvgPathFromStroke(outline); + const path = new Path2D(pathStr); + ctx.fill(path); } function drawTraceBackground(ctx: CanvasRenderingContext2D) { @@ -203,11 +212,17 @@ } function isPenLike(e: PointerEvent): boolean { - // 사용자 요청: Apple Pencil ('pen') 만 인식. 손가락 ('touch') 거부 — palm rejection. - // 데스크톱 테스트용 'mouse' 는 허용. + // Apple Pencil ('pen') 만 인식. 손가락 ('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 { @@ -236,8 +251,7 @@ if (!canvas || !isPenLike(e)) return; e.preventDefault(); - // 이전 stroke 의 inflight 가 어떤 이유로 살아있다면 강제 종료 (완성된 stroke 로 보존). - // 빠른 연속 입력 시 두번째 stroke 가 누락되는 race 방어. + // 이전 stroke 의 inflight 가 어떤 이유로 살아있다면 강제 finalize (race 방어). if (inflight) { if (inflight.points.length > 1) { strokes = [...strokes, inflight]; @@ -263,16 +277,19 @@ inflight = { id: `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - points: [[x, y, e.pressure || 0.5]], + points: [[x, y, normalizePressure(e.pressure)]], }; redraw(); } function onPointerMove(e: PointerEvent) { - if (e.pointerId !== activePointerId) return; + // pointerId 매칭 완화: pen 인 동일 pointer 면 처리 (race 방어). + if (!isPenLike(e)) 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) { isDirty = true; @@ -284,27 +301,35 @@ } if (!inflight) return; - // coalesced events 가 있으면 보간으로만 사용. main 점은 항상 push 보장. + // coalesced events: 빠른 필기에서 샘플 간격을 좁히기 위해 모두 반영. const coalesced = (e.getCoalescedEvents?.() ?? []) as PointerEvent[]; - for (const ev of coalesced) { - const [cx, cy] = getLocalXY(ev); - inflight.points.push([cx, cy, ev.pressure || 0.5]); + if (coalesced.length > 0) { + for (const ev of coalesced) { + const [cx, cy] = getLocalXY(ev); + inflight.points.push([cx, cy, normalizePressure(ev.pressure)]); + } + } else { + inflight.points.push([x, y, normalizePressure(e.pressure)]); } - inflight.points.push([x, y, e.pressure || 0.5]); redraw(); } function endStroke(e: PointerEvent) { - // pointerleave 가 stale pointerId 로 들어와 새 stroke 를 강제 종료하던 race 방어: - // inflight 가 없으면 (첫 stroke 이미 끝났음) 무시. + // pointerleave race 방어: inflight 가 이미 없으면 그냥 정리. if (!inflight) { if (e.pointerId === activePointerId) activePointerId = null; return; } - if (e.pointerId !== activePointerId) return; + // pointerId 미스매치라도 inflight 가 살아있으면 finalize 시도 (Apple Pencil 의 + // pointerId 가 가끔 재사용되거나 변경되는 케이스 방어). 단 다른 pointer 의 + // pointerleave 가 stale 하게 들어왔다면 무시. + if (e.pointerId !== activePointerId && e.type !== 'pointerup' && e.type !== 'pointercancel') return; activePointerId = null; - if (tool === 'eraser') return; + if (tool === 'eraser') { + inflight = null; + return; + } if (inflight.points.length > 1) { strokes = [...strokes, inflight]; @@ -441,6 +466,12 @@ redraw(); } }); + + // toolbar/header button 공통 inline style — iPad Safari 더블탭 줌 / long-press + // 메뉴 / 텍스트 선택 / tap highlight 모두 차단. 각 button 에 직접 적용. + const BTN_STYLE = + 'touch-action: manipulation; user-select: none; -webkit-user-select: none; ' + + '-webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;';
@@ -450,6 +481,7 @@ +
+ +
+