fix(study): Notability 수준 필기감 + 연속 stroke race 방어
필기감:
- perfect-freehand 재도입 (effect race 제거됐으니 안전)
- thinning 0.6, smoothing 0.65, streamline 0.5
- simulatePressure false → 실제 e.pressure 반영
- outline polygon 을 quadratic bezier 로 연결 → 부드러운 곡선 (직선 segment ❌)
- ctx.fill() anti-aliased
UI:
- 굵기 토글 (가늘게/보통/굵게) — baseSize × {0.6, 1, 1.6}
- Pencil only (touch 차단)
연속 stroke race fix:
- setPointerCapture/release 제거 → 빠른 pointerup→pointerdown race 차단
- onPointerDown 시 이전 inflight 강제 보존 (드물지만 stale 한 경우)
- pointerleave 핸들러는 inflight 가 살아있을 때만 endStroke
- endStroke: inflight 없으면 즉시 return, activePointerId 만 정리
이전 보고: "ㄱ 쓰고 ㅏ 바로 쓰면 ㅏ 가 입력 안됨" 핵심 원인은 stale
pointerleave 가 두번째 stroke 를 강제 종료시킨 것. 위 race fix 로 해결.
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
* (전체 지우기 버튼은 별도)
|
||||
*/
|
||||
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';
|
||||
|
||||
@@ -72,11 +73,17 @@
|
||||
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]);
|
||||
|
||||
// 디버그: 캔버스 크기 + 마지막 pointer 정보. 좌표 어긋남 디버깅용.
|
||||
let lastDebug = $state<{x:number;y:number;p:number;t:string} | null>(null);
|
||||
|
||||
// 지우개 반경 (CSS px) — baseSize 의 약 4배. Apple Pencil 정밀도와 균형.
|
||||
let eraserRadius = $derived(Math.max(16, baseSize * 4));
|
||||
// 지우개 반경 (CSS px) — effectiveSize 의 약 4배. Apple Pencil 정밀도와 균형.
|
||||
let eraserRadius = $derived(Math.max(16, effectiveSize * 4));
|
||||
|
||||
// ── localStorage backup ──
|
||||
const lsKey = $derived(`study_session_${sessionId}_strokes`);
|
||||
@@ -117,27 +124,39 @@
|
||||
redraw();
|
||||
}
|
||||
|
||||
// ── render — 단순 ctx.stroke().
|
||||
// ── render — perfect-freehand polygon outline + bezier 보간으로 Notability 수준 필기감.
|
||||
// 알고리즘:
|
||||
// 1) getStroke() → 압력/속도 기반 polygon outline points
|
||||
// 2) outline 을 quadratic bezier 로 연결 → 부드러운 곡선
|
||||
// 3) ctx.fill() — anti-aliased polygon
|
||||
function drawStroke(ctx: CanvasRenderingContext2D, s: Stroke) {
|
||||
if (s.points.length === 0) return;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = baseSize;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
|
||||
const outline = getStroke(s.points, {
|
||||
size: effectiveSize,
|
||||
thinning: 0.6, // 압력 변화 폭. 강할수록 압력 차이 진함.
|
||||
smoothing: 0.65, // 점 보간 강도
|
||||
streamline: 0.5, // stroke 안정화 (떨림 감소)
|
||||
simulatePressure: false, // 실제 e.pressure 사용
|
||||
last: true,
|
||||
easing: (t) => t,
|
||||
});
|
||||
if (outline.length < 2) return;
|
||||
|
||||
ctx.fillStyle = strokeColor;
|
||||
if (s.points.length === 1) {
|
||||
const [x, y] = s.points[0];
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, baseSize / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
return;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.points[0][0], s.points[0][1]);
|
||||
for (let i = 1; i < s.points.length; i++) {
|
||||
ctx.lineTo(s.points[i][0], s.points[i][1]);
|
||||
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);
|
||||
}
|
||||
ctx.stroke();
|
||||
const last = outline[outline.length - 1];
|
||||
ctx.lineTo(last[0], last[1]);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawTraceBackground(ctx: CanvasRenderingContext2D) {
|
||||
@@ -213,7 +232,18 @@
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!canvas || !isPenLike(e)) return;
|
||||
e.preventDefault();
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
|
||||
// 이전 stroke 의 inflight 가 어떤 이유로 살아있다면 강제 종료 (완성된 stroke 로 보존).
|
||||
// 빠른 연속 입력 시 두번째 stroke 가 누락되는 race 방어.
|
||||
if (inflight) {
|
||||
if (inflight.points.length > 1) {
|
||||
strokes = [...strokes, inflight];
|
||||
isDirty = true;
|
||||
backupToLocalStorage();
|
||||
}
|
||||
inflight = null;
|
||||
}
|
||||
|
||||
activePointerId = e.pointerId;
|
||||
const [x, y] = getLocalXY(e);
|
||||
lastDebug = { x: Math.round(x), y: Math.round(y), p: e.pressure, t: e.pointerType };
|
||||
@@ -264,16 +294,17 @@
|
||||
}
|
||||
|
||||
function endStroke(e: PointerEvent) {
|
||||
if (e.pointerId !== activePointerId) return;
|
||||
canvas?.releasePointerCapture?.(e.pointerId);
|
||||
activePointerId = null;
|
||||
|
||||
if (tool === 'eraser') {
|
||||
// 지우개는 down/move 단계에서 즉시 삭제 + scheduleSave. 끝낼 일 없음.
|
||||
// pointerleave 가 stale pointerId 로 들어와 새 stroke 를 강제 종료하던 race 방어:
|
||||
// inflight 가 없으면 (첫 stroke 이미 끝났음) 무시.
|
||||
if (!inflight) {
|
||||
if (e.pointerId === activePointerId) activePointerId = null;
|
||||
return;
|
||||
}
|
||||
if (e.pointerId !== activePointerId) return;
|
||||
activePointerId = null;
|
||||
|
||||
if (tool === 'eraser') return;
|
||||
|
||||
if (!inflight) return;
|
||||
if (inflight.points.length > 1) {
|
||||
strokes = [...strokes, inflight];
|
||||
undoStack = [];
|
||||
@@ -413,7 +444,7 @@
|
||||
|
||||
<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" 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;">
|
||||
<!-- Pen / Eraser 모드 토글 -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -436,6 +467,19 @@
|
||||
|
||||
<span class="w-px h-5 bg-default mx-1" aria-hidden></span>
|
||||
|
||||
<!-- 굵기 선택 — 압력은 자동 반영, 이건 base 굵기 -->
|
||||
{#each [['thin', '가늘게'], ['normal', '보통'], ['thick', '굵게']] as [w, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (widthMode = w)}
|
||||
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={undo} disabled={strokes.length === 0} />
|
||||
<IconButton icon={Redo2} size="sm" aria-label="다시 실행" onclick={redo} disabled={undoStack.length === 0} />
|
||||
<IconButton icon={Trash2} size="sm" aria-label="모두 지우기" onclick={clearAll} disabled={strokes.length === 0} />
|
||||
@@ -463,7 +507,7 @@
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={endStroke}
|
||||
onpointercancel={endStroke}
|
||||
onpointerleave={(e) => { if (e.pointerId === activePointerId) endStroke(e); }}
|
||||
onpointerleave={(e) => { if (inflight && e.pointerId === activePointerId) endStroke(e); }}
|
||||
class="block"
|
||||
style="touch-action: none; cursor: {tool === 'eraser' ? 'cell' : 'crosshair'};"
|
||||
></canvas>
|
||||
|
||||
Reference in New Issue
Block a user