fix(study): 필기감 + 연속 stroke + 버튼 줌 차단 종합

P1 Safari 줌 차단:
- viewport meta 의 maximum-scale / user-scalable=no 제거 (접근성)
- 페이지 root div 의 ongesturestart/change/end preventDefault — 영역 제한
- 모든 toolbar/header button 에 직접 inline style 적용:
  touch-action: manipulation, user-select/-webkit-user-select: none,
  -webkit-touch-callout: none, -webkit-tap-highlight-color: transparent

P2 연속 stroke 누락:
- onPointerDown: 이전 inflight 강제 finalize 후 새 stroke 시작
- onPointerMove: pointerId 매칭 완화, isPenLike + inflight 만 체크
  (Apple Pencil pointerId 재사용/변경 케이스 방어)
- endStroke: pointerleave race 방어, pointerup/pointercancel 은 무조건 finalize
- 자동 저장 (PATCH) 은 fire-and-forget 그대로 — 입력과 분리

P3 점선 렌더링 품질:
- perfect-freehand 표준 getSvgPathFromStroke + Path2D fill 로 교체
  (직접 quadraticCurveTo 보다 안정적)
- thinning 0.5, smoothing 0.7, streamline 0.55 로 튜닝
- normalizePressure: 0/비정상 값은 0.5 fallback (점선 방지)
- coalesced events 모두 points 에 push (빠른 필기 샘플 간격 좁힘)
- 단일 점 (탭) 은 작은 원으로 폴백
This commit is contained in:
Hyungi Ahn
2026-04-27 10:30:12 +09:00
parent 20d4457a75
commit 1a560b5fde
2 changed files with 99 additions and 63 deletions
@@ -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;';
</script>
<div class="flex flex-col h-full" style="touch-action: manipulation;">
@@ -450,6 +481,7 @@
<button
type="button"
onclick={() => (tool = 'pen')}
style={BTN_STYLE}
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'}
@@ -459,6 +491,7 @@
<button
type="button"
onclick={() => (tool = 'eraser')}
style={BTN_STYLE}
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'}
@@ -473,6 +506,7 @@
<button
type="button"
onclick={() => (widthMode = w)}
style={BTN_STYLE}
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}
@@ -481,9 +515,9 @@
<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} />
<IconButton icon={Undo2} size="sm" aria-label="실행 취소" onclick={undo} disabled={strokes.length === 0} style={BTN_STYLE} />
<IconButton icon={Redo2} size="sm" aria-label="다시 실행" onclick={redo} disabled={undoStack.length === 0} style={BTN_STYLE} />
<IconButton icon={Trash2} size="sm" aria-label="모두 지우기" onclick={clearAll} disabled={strokes.length === 0} style={BTN_STYLE} />
<span class="text-xs text-dim ml-2">stroke {strokes.length}</span>
<div class="flex-1"></div>
@@ -494,6 +528,7 @@
type="button"
onclick={takeSnapshot}
disabled={snapshotting || strokes.length === 0}
style={BTN_STYLE}
class="px-3 py-1 rounded text-sm bg-accent text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{snapshotting ? '저장 중...' : 'PNG 저장'}
@@ -6,7 +6,7 @@
* 모바일에서도 캔버스가 화면을 거의 전부 차지하도록.
* 메타 편집 / asset 목록은 헤더 "패널" 버튼으로 열고 닫는다.
*/
import { onMount, onDestroy } from 'svelte';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api, uploadFile } from '$lib/api';
import { addToast } from '$lib/stores/toast';
@@ -36,23 +36,15 @@
}
}
// iOS Safari 핀치줌 차단 — 손가락 두 개로 캔버스 만질 때 페이지 zoom 발생 방지.
// touch-action: none / manipulation 만으로 막히지 않는 gesture 이벤트 (비표준) 도 차단.
// iOS Safari 핀치/zoom gesture 차단 — 페이지 root 영역에만 적용 (영역 제한).
function blockGesture(e) { e.preventDefault(); }
onMount(() => {
load();
document.addEventListener('gesturestart', blockGesture, { passive: false });
document.addEventListener('gesturechange', blockGesture, { passive: false });
document.addEventListener('gestureend', blockGesture, { passive: false });
});
onDestroy(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('gesturestart', blockGesture);
document.removeEventListener('gesturechange', blockGesture);
document.removeEventListener('gestureend', blockGesture);
}
});
// 모든 button 공통 inline style — 더블탭 줌 / long-press 메뉴 / 텍스트 선택 차단.
const BTN_STYLE =
'touch-action: manipulation; user-select: none; -webkit-user-select: none; ' +
'-webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;';
onMount(() => { load(); });
async function patchSession(partial) {
try {
@@ -125,18 +117,26 @@
</script>
<svelte:head>
<!-- 학습 세션 페이지에서만 페이지 줌 차단. unmount 시 자동 해제. -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, viewport-fit=cover" />
<!-- 접근성을 위해 user-scalable / maximum-scale 강제는 적용하지 않는다.
핀치줌 차단은 페이지 root 의 ongesturestart 등으로 영역 제한. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
</svelte:head>
<div class="h-full flex flex-col relative" style="touch-action: manipulation; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;">
<!-- 상단 헤더 -->
<div class="flex items-center justify-between gap-2 px-3 py-2 border-b border-default bg-surface shrink-0 z-10">
<Button href="/study/write" size="sm" variant="ghost" icon={ArrowLeft}>목록</Button>
<div
class="h-full flex flex-col relative"
style="touch-action: manipulation; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;"
ongesturestart={blockGesture}
ongesturechange={blockGesture}
ongestureend={blockGesture}
>
<!-- 상단 헤더 — 모든 button 에 BTN_STYLE 직접 적용 -->
<div class="flex items-center justify-between gap-2 px-3 py-2 border-b border-default bg-surface shrink-0 z-10" style="touch-action: manipulation;">
<Button href="/study/write" size="sm" variant="ghost" icon={ArrowLeft} style={BTN_STYLE}>목록</Button>
<button
type="button"
onclick={() => (panelOpen = !panelOpen)}
style={BTN_STYLE}
class="flex items-center gap-1 px-2 py-1 rounded text-xs text-text hover:bg-bg"
aria-label="메타 패널 토글"
>
@@ -158,6 +158,7 @@
<button
type="button"
onclick={bumpRep}
style={BTN_STYLE}
class="flex items-center gap-1 px-2 py-1 rounded text-xs bg-accent/10 text-accent hover:bg-accent/15"
>
<RotateCw size={12} /> 다음 시도 ({repCount})