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:
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user